Mindlist v1: FOSS Neurolist clone (PWA + Capacitor Android), v3 logo, signed release
This commit is contained in:
commit
4fc6be04da
90 files changed
+10761
No files matched your search
+184
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
+690
@@ -0,0 +1,690 @@
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import {
|
||||
Brain,
|
||||
CalendarPlus,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
Home,
|
||||
ListTodo,
|
||||
Moon,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Trash2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { beep, heuristicPlan, speak } from './lib/fallback';
|
||||
import {
|
||||
callOpenRouter,
|
||||
getKey,
|
||||
getModel,
|
||||
setKey,
|
||||
setModel,
|
||||
type GenPlan,
|
||||
} from './lib/openrouter';
|
||||
import { buildAiEditSystem, buildBrainDumpSystem, buildSystemPrompt, type Difficulty } from './lib/prompts';
|
||||
import {
|
||||
db,
|
||||
downloadICS,
|
||||
getDark,
|
||||
getLifeContext,
|
||||
getVoiceOn,
|
||||
setDark,
|
||||
setLifeContext,
|
||||
setVoiceOn,
|
||||
uid,
|
||||
type Step,
|
||||
type Task,
|
||||
} from './lib/store';
|
||||
|
||||
const CATS = ['Work', 'Home', 'Personal', 'Health'] as const;
|
||||
const CAT_EMOJI: Record<string, string> = { Work: '💼', Home: '🏠', Personal: '🌱', Health: '💚' };
|
||||
|
||||
function fmt(s: number) {
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function projectedEnd(steps: Step[]): Date {
|
||||
const remain = steps.filter((s) => s.status !== 'done').reduce((a, s) => a + s.secondsLeft, 0);
|
||||
return new Date(Date.now() + remain * 1000);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState<'today' | 'new' | 'dump' | 'library'>('today');
|
||||
const [catFilter, setCatFilter] = useState<string>('All');
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [focusMode, setFocusMode] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showAiEdit, setShowAiEdit] = useState(false);
|
||||
const [dark, setDarkState] = useState(getDark());
|
||||
const [voice, setVoice] = useState(getVoiceOn());
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
setDark(dark);
|
||||
}, [dark]);
|
||||
|
||||
const tasks = useLiveQuery(() => db.tasks.orderBy('createdAt').reverse().toArray(), []) ?? [];
|
||||
const activeTask = tasks.find((t) => t.id === activeTaskId) ?? tasks.find((t) => t.status !== 'done') ?? null;
|
||||
const activeSteps = useLiveQuery(
|
||||
() => (activeTask ? db.steps.where('taskId').equals(activeTask.id).sortBy('order') : Promise.resolve([] as Step[])),
|
||||
[activeTask?.id],
|
||||
) ?? [];
|
||||
|
||||
const doneCount = tasks.filter((t) => t.status === 'done').length;
|
||||
const filtered = useMemo(
|
||||
() => (catFilter === 'All' ? tasks : tasks.filter((t) => t.category === catFilter)),
|
||||
[tasks, catFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#f6f7f7] text-slate-800 dark:bg-[#0f1115] dark:text-slate-100">
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-[480px] flex-col border-x border-black/5 bg-white dark:border-white/10 dark:bg-[#15181e]">
|
||||
{/* header */}
|
||||
<header className="sticky top-0 z-20 flex items-center justify-between bg-teal-700 px-4 pb-3 pt-[max(1rem,env(safe-area-inset-top))] text-white dark:bg-teal-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="logo.svg" alt="" aria-hidden="true" className="size-9 rounded-xl" />
|
||||
<div>
|
||||
<p className="text-[15px] font-bold leading-none">mindlist</p>
|
||||
<p className="text-[11px] text-teal-50">free ADHD task breaker</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button aria-label={voice ? 'Mute voice announcements' : 'Enable voice announcements'} aria-pressed={voice} onClick={() => { const v = !voice; setVoice(v); setVoiceOn(v); }}
|
||||
className="grid size-11 place-items-center rounded-full hover:bg-white/20">{voice ? <Volume2 size={18} /> : <VolumeX size={18} />}</button>
|
||||
<button aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'} aria-pressed={dark} onClick={() => setDarkState(!dark)}
|
||||
className="grid size-11 place-items-center rounded-full hover:bg-white/20">{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
|
||||
<button aria-label="Open settings" onClick={() => setShowSettings(true)}
|
||||
className="grid size-11 place-items-center rounded-full hover:bg-white/20"><Settings2 size={18} /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* category tabs */}
|
||||
<div className="flex gap-2 overflow-x-auto px-4 py-3" role="toolbar" aria-label="Filter by category">
|
||||
{['All', ...CATS].map((c) => (
|
||||
<button key={c} aria-pressed={catFilter === c} onClick={() => setCatFilter(c)}
|
||||
className={`min-h-[44px] shrink-0 rounded-full px-4 py-2 text-[13px] font-medium ${catFilter === c ? 'bg-teal-700 text-white' : 'bg-slate-100 text-slate-700 dark:bg-white/10 dark:text-slate-200'}`}>
|
||||
{c === 'All' ? 'All' : `${CAT_EMOJI[c]} ${c}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<main className="flex-1 px-4 pb-28">
|
||||
{view === 'today' && (
|
||||
<TodayView tasks={filtered} doneCount={doneCount} activeSteps={activeSteps}
|
||||
onOpen={(t) => { setActiveTaskId(t.id); setView('new'); }}
|
||||
onFocus={(t) => { setActiveTaskId(t.id); setFocusMode(true); }} />
|
||||
)}
|
||||
{view === 'new' && (
|
||||
<ComposerView activeTask={activeTask} activeSteps={activeSteps}
|
||||
onCreated={(id) => setActiveTaskId(id)}
|
||||
onFocus={() => setFocusMode(true)}
|
||||
onEdit={() => setShowAiEdit(true)} />
|
||||
)}
|
||||
{view === 'dump' && <DumpView onCreated={(id) => { setActiveTaskId(id); setView('new'); }} />}
|
||||
{view === 'library' && (
|
||||
<LibraryView tasks={tasks}
|
||||
onOpen={(t) => { setActiveTaskId(t.id); setView('new'); }}
|
||||
onReuse={async (t) => {
|
||||
const steps = await db.steps.where('taskId').equals(t.id).sortBy('order');
|
||||
const nid = uid();
|
||||
await db.tasks.add({ ...t, id: nid, status: 'planned', createdAt: Date.now(), completedAt: undefined });
|
||||
for (const [i, s] of steps.entries())
|
||||
await db.steps.add({ ...s, id: uid(), taskId: nid, order: i, status: 'todo', secondsLeft: s.minutes * 60 });
|
||||
setActiveTaskId(nid); setView('new');
|
||||
}} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* bottom nav */}
|
||||
<nav className="fixed bottom-0 left-1/2 z-20 w-full max-w-[480px] -translate-x-1/2 border-t border-black/10 bg-white/95 px-6 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-2 backdrop-blur dark:border-white/10 dark:bg-[#15181e]/95">
|
||||
<div className="flex items-center justify-between">
|
||||
<NavBtn icon={<Home size={20} />} label="Today" active={view === 'today'} onClick={() => setView('today')} />
|
||||
<NavBtn icon={<Plus size={20} />} label="New" active={view === 'new'} onClick={() => setView('new')} />
|
||||
<button onClick={() => { if (activeTaskId) { setFocusMode(true); } else { setView('new'); } }} aria-label={activeTaskId ? "Start focus timer" : "Create new task"}
|
||||
className="grid size-12 place-items-center rounded-full bg-teal-700 text-2xl text-white"><Sparkles size={22} /></button>
|
||||
<NavBtn icon={<Brain size={20} />} label="Dump" active={view === 'dump'} onClick={() => setView('dump')} />
|
||||
<NavBtn icon={<ListTodo size={20} />} label="Library" active={view === 'library'} onClick={() => setView('library')} />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{focusMode && activeTask && (
|
||||
<FocusMode task={activeTask} steps={activeSteps} voice={voice} onClose={() => setFocusMode(false)} />
|
||||
)}
|
||||
{showAiEdit && activeTask && (
|
||||
<AiEditSheet task={activeTask} steps={activeSteps} onClose={() => setShowAiEdit(false)} />
|
||||
)}
|
||||
{showSettings && <SettingsSheet onClose={() => setShowSettings(false)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavBtn({ icon, label, active, onClick }: { icon: React.ReactNode; label: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick} aria-current={active ? 'page' : undefined} className={`flex min-h-[48px] min-w-[56px] flex-col items-center justify-center gap-0.5 px-2 py-1 text-[11px] font-medium ${active ? 'text-teal-700 dark:text-teal-300' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
{icon}{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Today ---------------- */
|
||||
function TodayView({ tasks, doneCount, activeSteps, onOpen, onFocus }: {
|
||||
tasks: Task[]; doneCount: number; activeSteps: Step[];
|
||||
onOpen: (t: Task) => void; onFocus: (t: Task) => void;
|
||||
}) {
|
||||
const today = new Date().toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||
const active = tasks.find((t) => t.status !== 'done');
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl bg-teal-50 p-4 dark:bg-white/5">
|
||||
<p className="text-[13px] text-slate-500 dark:text-slate-400">📅 {today} · 🎉 {doneCount} tasks overall completed</p>
|
||||
{active ? (
|
||||
<div className="mt-2">
|
||||
<p className="text-[15px] font-semibold">▶ {active.title}</p>
|
||||
<p className="text-[12px] text-slate-500">{activeSteps.filter((s) => s.status === 'done').length}/{activeSteps.length} steps · ~{active.totalMin} min</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button onClick={() => onFocus(active)} className="min-h-[44px] rounded-xl bg-teal-700 px-4 py-2 text-[13px] font-semibold text-white">Continue focus</button>
|
||||
<button onClick={() => onOpen(active)} className="rounded-xl bg-white px-4 py-2 text-[13px] font-semibold text-teal-700 shadow dark:bg-white/10 dark:text-teal-200">Details</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[14px]">Nothing planned. Tap <b>+ New</b> or <b>Dump</b> to break a scary task down. 🌱</p>
|
||||
)}
|
||||
</div>
|
||||
{tasks.map((t) => (
|
||||
<button key={t.id} onClick={() => onOpen(t)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-black/5 bg-white p-3 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<span className="text-xl">{CAT_EMOJI[t.category] ?? '✅'}</span>
|
||||
<span className="flex-1">
|
||||
<span className={`block text-[14px] font-medium ${t.status === 'done' ? 'line-through opacity-60' : ''}`}>{t.title}</span>
|
||||
<span className="text-[12px] text-slate-500">{t.difficulty} · {t.totalMin} min · {t.status}</span>
|
||||
</span>
|
||||
<ChevronRight size={16} className="text-slate-300" />
|
||||
</button>
|
||||
))}
|
||||
{tasks.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-teal-300 p-6 text-center text-[13px] text-slate-500">
|
||||
🧠 #1 AI Planner for ADHD & neurodivergence<br />Break down stressful tasks → focus timer keeps you on track.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Composer ---------------- */
|
||||
function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: {
|
||||
activeTask: Task | null; activeSteps: Step[];
|
||||
onCreated: (id: string) => void; onFocus: () => void; onEdit: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState<string>('Home');
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>('medium');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
const t = title.trim() || activeTask?.title || '';
|
||||
if (!t) { setErr('Type a task first — e.g. “clean my room”.'); return; }
|
||||
setLoading(true); setErr(''); setNotice('');
|
||||
try {
|
||||
const ctx = getLifeContext();
|
||||
let plan: GenPlan;
|
||||
if (getKey()) {
|
||||
try {
|
||||
const { plan: p, raw } = await callOpenRouter({ system: buildSystemPrompt(difficulty, ctx), user: t });
|
||||
if (!p) throw new Error(`AI returned invalid JSON. Raw: ${raw.slice(0, 200)}`);
|
||||
plan = p;
|
||||
} catch (e) {
|
||||
// offline / 429 / 5xx fallback so timer still works
|
||||
plan = heuristicPlan(t, difficulty);
|
||||
setNotice(`Offline mode — AI unavailable, used built-in breakdown. Add key in ⚙️ for smarter plans.`);
|
||||
}
|
||||
} else {
|
||||
plan = heuristicPlan(t, difficulty); // offline demo mode
|
||||
}
|
||||
const id = activeTask && activeTask.title === t ? activeTask.id : uid();
|
||||
if (activeTask && activeTask.title === t) {
|
||||
await db.steps.where('taskId').equals(id).delete();
|
||||
await db.tasks.update(id, { difficulty, category, totalMin: plan.totalMin, status: 'planned', lifeContext: ctx });
|
||||
} else {
|
||||
await db.tasks.add({ id, title: t, category, difficulty, lifeContext: ctx, totalMin: plan.totalMin, status: 'planned', createdAt: Date.now() });
|
||||
}
|
||||
for (const [i, s] of plan.steps.entries())
|
||||
await db.steps.add({ id: uid(), taskId: id, order: i, emoji: s.emoji, text: s.text, minutes: s.minutes, secondsLeft: s.minutes * 60, status: 'todo' });
|
||||
onCreated(id);
|
||||
setTitle('');
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Generation failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [title, activeTask, category, difficulty, onCreated]);
|
||||
|
||||
const toggleStep = async (s: Step) => {
|
||||
await db.steps.update(s.id, { status: s.status === 'done' ? 'todo' : 'done', secondsLeft: s.status === 'done' ? s.minutes * 60 : 0 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl bg-slate-100 p-3 dark:bg-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="task-title" className="sr-only">Task title</label>
|
||||
<input id="task-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder={activeTask?.title ?? 'clean my room'}
|
||||
className="flex-1 rounded-xl border border-black/10 bg-white px-3 py-2.5 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||
<button onClick={generate} disabled={loading}
|
||||
className="flex min-h-[44px] items-center gap-1 rounded-xl bg-teal-700 px-3 py-2.5 text-[13px] font-bold text-white disabled:opacity-50">
|
||||
<Wand2 size={15} /> {loading ? '…' : 'Break down'}</button>
|
||||
</div>
|
||||
{!getKey() && <p className="mt-1.5 text-[11px] text-amber-600 dark:text-amber-300">⚙️ No API key — using offline demo breakdown. Add BYOK key in ⚙️ for real AI.</p>}
|
||||
<fieldset className="mt-2 flex gap-1.5">
|
||||
<legend className="sr-only">Difficulty</legend>
|
||||
{(['easy', 'medium', 'hard'] as Difficulty[]).map((d) => (
|
||||
<button key={d} aria-pressed={difficulty === d} onClick={() => setDifficulty(d)}
|
||||
className={`min-h-[44px] flex-1 rounded-xl px-2 py-2 text-[12px] font-semibold capitalize ${difficulty === d ? 'bg-slate-900 text-white dark:bg-teal-300 dark:text-teal-950' : 'bg-white text-slate-500 dark:bg-black/30 dark:text-slate-300'}`}>{d}</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<fieldset className="mt-2 flex gap-1.5">
|
||||
<legend className="sr-only">Category</legend>
|
||||
{CATS.map((c) => (
|
||||
<button key={c} aria-pressed={category === c} onClick={() => setCategory(c)}
|
||||
className={`min-h-[44px] flex-1 rounded-xl px-2 py-2 text-[12px] ${category === c ? 'bg-teal-100 font-bold text-teal-800 dark:bg-teal-600/20 dark:text-teal-100' : 'bg-white text-slate-500 dark:bg-black/30 dark:text-slate-300'}`}>{CAT_EMOJI[c]} {c}</button>
|
||||
))}
|
||||
</fieldset>
|
||||
{err && <p role="alert" className="mt-2 text-[12px] text-red-600 dark:text-red-300">{err}</p>}
|
||||
{notice && <p aria-live="polite" className="mt-2 text-[12px] text-amber-700 dark:text-amber-300">{notice}</p>}
|
||||
</div>
|
||||
|
||||
{activeTask && (
|
||||
<div className="overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-center justify-between bg-slate-50 px-3 py-2 dark:bg-black/20">
|
||||
<p className="text-[13px] font-semibold">{activeTask.title} <span className="font-normal text-slate-400">· overall duration: {activeTask.totalMin} min</span></p>
|
||||
<button onClick={onEdit} className="min-h-[44px] rounded-lg bg-teal-700 px-3 py-2 text-[12px] font-bold text-white dark:bg-teal-600/20 dark:text-teal-100">🤖 AI Edit</button>
|
||||
</div>
|
||||
<div className="nice-scroll max-h-[46dvh] divide-y divide-black/5 overflow-y-auto dark:divide-white/5">
|
||||
{activeSteps.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-2 px-3 py-1.5">
|
||||
<button onClick={() => toggleStep(s)} aria-label={s.status === 'done' ? `Mark "${s.text}" not done` : `Mark "${s.text}" done`}
|
||||
className={`grid size-11 shrink-0 place-items-center rounded-full border ${s.status === 'done' ? 'border-teal-600 bg-teal-600 text-white' : 'border-slate-300 text-transparent'}`}>
|
||||
<Check size={15} /></button>
|
||||
<span className="text-[15px]">{s.emoji}</span>
|
||||
<span className={`flex-1 text-[13.5px] ${s.status === 'done' ? 'line-through opacity-50' : ''}`}>{s.text}</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-500 dark:bg-white/10 dark:text-slate-300">{s.minutes} m</span>
|
||||
</div>
|
||||
))}
|
||||
{activeSteps.length === 0 && <p className="p-4 text-center text-[13px] text-slate-400">No steps yet — hit Break down.</p>}
|
||||
</div>
|
||||
<div className="flex gap-2 p-3">
|
||||
<button onClick={onFocus} disabled={activeSteps.length === 0}
|
||||
className="min-h-[48px] flex-1 rounded-xl bg-teal-700 py-2.5 text-[14px] font-bold text-white disabled:opacity-40">▶ Start focus playlist</button>
|
||||
<button onClick={async () => { if (activeTask) { downloadICS(activeTask, activeSteps); } }}
|
||||
aria-label="Export to calendar (.ics)" className="grid min-h-[44px] min-w-[44px] place-items-center rounded-xl border border-black/10 text-slate-600 dark:border-white/10 dark:text-slate-300">
|
||||
<CalendarPlus size={17} /></button>
|
||||
<button onClick={async () => {
|
||||
if (!activeTask) return;
|
||||
await db.steps.where('taskId').equals(activeTask.id).delete();
|
||||
await db.tasks.delete(activeTask.id);
|
||||
}} aria-label="Delete this task" className="grid min-h-[44px] min-w-[44px] place-items-center rounded-xl border border-black/10 text-slate-500 dark:border-white/10"><Trash2 size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Focus ---------------- */
|
||||
function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[]; voice: boolean; onClose: () => void }) {
|
||||
const ordered = useMemo(() => [...steps].sort((a, b) => a.order - b.order), [steps]);
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [running, setRunning] = useState(true);
|
||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||
const cur = ordered[Math.min(idx, Math.max(0, ordered.length - 1))];
|
||||
const announced = useRef('');
|
||||
|
||||
// Sync to first unfinished when task changes (fixes stale idx + resume-at-0)
|
||||
useEffect(() => {
|
||||
if (syncedFor !== task.id && ordered.length > 0) {
|
||||
const i = ordered.findIndex((s) => s.status !== 'done' && s.status !== 'skipped');
|
||||
setIdx(i < 0 ? 0 : i);
|
||||
setSyncedFor(task.id);
|
||||
} else if (ordered.length && idx >= ordered.length) setIdx(ordered.length - 1);
|
||||
}, [ordered, idx, syncedFor, task.id]);
|
||||
|
||||
// Esc to close + dialog semantics
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
let lock: { release?: () => void } | null = null;
|
||||
(navigator as unknown as { wakeLock?: { request: (k: string) => Promise<{ release: () => void }> } }).wakeLock?.request('screen').then((l) => { lock = l; }).catch(() => {});
|
||||
return () => { try { lock?.release?.(); } catch { /* noop */ } };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cur || !running) return;
|
||||
if (announced.current !== cur.id) {
|
||||
announced.current = cur.id;
|
||||
speak(`${cur.text}. ${Math.max(1, Math.round(cur.secondsLeft / 60))} minutes.`, voice && getVoiceOn());
|
||||
if (cur.status === 'todo') db.steps.update(cur.id, { status: 'active' }).catch(() => {});
|
||||
}
|
||||
if (cur.secondsLeft <= 0) {
|
||||
beep();
|
||||
db.steps.update(cur.id, { status: 'done' }).catch(() => {});
|
||||
const next = ordered[idx + 1];
|
||||
if (next) {
|
||||
// Reset next if revisiting a finished step so it doesn't instantly skip
|
||||
if (next.status === 'done' && next.secondsLeft <= 0)
|
||||
db.steps.update(next.id, { status: 'todo', secondsLeft: next.minutes * 60 }).catch(() => {});
|
||||
speak(`Done. Next: ${next.text}`, voice && getVoiceOn());
|
||||
setIdx(idx + 1);
|
||||
} else {
|
||||
speak('All steps finished. Well done.', voice && getVoiceOn());
|
||||
db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {});
|
||||
setRunning(false);
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
db.steps.update(cur.id, { secondsLeft: Math.max(0, cur.secondsLeft - 1) }).catch(() => {});
|
||||
}, 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [cur, running, idx, ordered, task.id, voice, onClose]);
|
||||
|
||||
if (!cur) return null;
|
||||
const total = cur.minutes * 60 || 1;
|
||||
const frac = Math.max(0, Math.min(1, cur.secondsLeft / total));
|
||||
const R = 84; const C = 2 * Math.PI * R;
|
||||
|
||||
const goBack = () => {
|
||||
const prev = Math.max(0, idx - 1);
|
||||
const target = ordered[prev];
|
||||
// Reset target so going back never lands on 0s (fixes instant auto-skip)
|
||||
if (target && target.secondsLeft <= 0)
|
||||
db.steps.update(target.id, { status: 'todo', secondsLeft: Math.max(60, target.minutes * 60) }).catch(() => {});
|
||||
else if (target && target.status === 'done')
|
||||
db.steps.update(target.id, { status: 'todo' }).catch(() => {});
|
||||
setIdx(prev);
|
||||
};
|
||||
const completeStep = () => {
|
||||
db.steps.update(cur.id, { status: 'done', secondsLeft: 0 }).catch(() => {});
|
||||
if (idx + 1 < ordered.length) setIdx(idx + 1);
|
||||
else { db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); onClose(); }
|
||||
};
|
||||
const skipStep = () => {
|
||||
db.steps.update(cur.id, { status: 'skipped', secondsLeft: 0 }).catch(() => {});
|
||||
if (idx + 1 < ordered.length) setIdx(idx + 1);
|
||||
else { db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); onClose(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-4">
|
||||
<div role="dialog" aria-modal="true" aria-label={`Focus: ${cur.text}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="focus-breathe w-full max-w-[380px] rounded-3xl bg-white p-5 text-center shadow-xl dark:bg-[#1b1f27]">
|
||||
<div className="flex items-center justify-between text-[13px] text-slate-600 dark:text-slate-300">
|
||||
<button onClick={onClose} aria-label="Back to list" className="flex min-h-[44px] min-w-[44px] items-center justify-center gap-1 rounded-xl"><ChevronLeft size={16} /> List</button>
|
||||
<span aria-live="off">Step {idx + 1} of {ordered.length}</span>
|
||||
<details className="text-[12px]"><summary className="flex min-h-[44px] min-w-[44px] cursor-pointer items-center gap-1" aria-label="Show projected end time"><Clock size={13} /> {projectedEnd(ordered).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</summary>
|
||||
<span>Ends ~{projectedEnd(ordered).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} at current pace</span></details>
|
||||
</div>
|
||||
<h2 className="mt-1 text-[12px] font-medium text-slate-500 dark:text-slate-400">{task.title}</h2>
|
||||
<div className="relative mx-auto mt-2 size-[220px]">
|
||||
<svg viewBox="0 0 200 200" className="size-full -rotate-90" aria-hidden="true">
|
||||
<circle cx="100" cy="100" r={R} fill="none" strokeWidth="14" className="stroke-slate-200 dark:stroke-white/10" />
|
||||
<circle cx="100" cy="100" r={R} fill="none" stroke="#0f766e" strokeWidth="14" strokeLinecap="round"
|
||||
className="timer-ring" strokeDasharray={C} strokeDashoffset={C * (1 - frac)} />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p className="text-3xl font-bold tabular-nums" role="timer" aria-label={`${fmt(cur.secondsLeft)} remaining`}>{fmt(cur.secondsLeft)}</p>
|
||||
<p className="max-w-[180px] text-[14px] font-medium">{cur.emoji} {cur.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-6 mt-1 h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-white/10" role="progressbar" aria-label="Time remaining" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(frac * 100)}>
|
||||
<div className="h-full rounded-full bg-teal-700" style={{ width: `${frac * 100}%` }} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-center gap-3">
|
||||
<button onClick={goBack} aria-label="Go to previous step" className="grid size-12 place-items-center rounded-full bg-slate-100 dark:bg-white/10"><ChevronLeft size={19} /></button>
|
||||
<button onClick={() => setRunning(!running)} aria-label={running ? 'Pause timer' : 'Resume timer'} aria-pressed={running}
|
||||
className="grid size-14 place-items-center rounded-full bg-teal-700 text-white">
|
||||
{running ? <Pause size={22} /> : <Play size={22} />}</button>
|
||||
<button onClick={completeStep} aria-label="Complete this step" className="grid size-12 place-items-center rounded-full bg-teal-700 text-white"><Check size={19} /></button>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-center gap-2">
|
||||
<button onClick={() => db.steps.update(cur.id, { secondsLeft: cur.secondsLeft + 60, minutes: cur.minutes + 1 }).catch(() => {})}
|
||||
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">+1 min</button>
|
||||
<button onClick={skipStep} aria-label="Skip this step"
|
||||
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">Skip →</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-slate-400">One step at a time. Future steps hidden to protect focus.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Brain dump ---------------- */
|
||||
function DumpView({ onCreated }: { onCreated: (id: string) => void }) {
|
||||
const [text, setText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const go = async () => {
|
||||
if (!text.trim()) { setErr('Dump something first.'); return; }
|
||||
setLoading(true); setErr('');
|
||||
try {
|
||||
const offlineSplit = () =>
|
||||
text.split(/\n|;/).map((x) => x.trim()).filter(Boolean).slice(0, 8)
|
||||
.map((x) => ({ emoji: '📌', text: x.slice(0, 60), minutes: 15 }));
|
||||
let items: { emoji: string; text: string; minutes: number }[];
|
||||
if (getKey()) {
|
||||
try {
|
||||
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${getKey()}`, 'Content-Type': 'application/json', 'HTTP-Referer': window.location.origin, 'X-Title': 'Mindlist FOSS' },
|
||||
body: JSON.stringify({
|
||||
model: getModel(),
|
||||
messages: [{ role: 'system', content: buildBrainDumpSystem(getLifeContext()) }, { role: 'user', content: text }],
|
||||
response_format: { type: 'json_object' }, max_tokens: 1200, temperature: 0.5,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`OpenRouter ${res.status}`);
|
||||
const j = await res.json();
|
||||
const raw: string = j?.choices?.[0]?.message?.content ?? '{}';
|
||||
const cleaned = raw.replace(/```json|```/g, '');
|
||||
const s = cleaned.indexOf('{'); const e = cleaned.lastIndexOf('}');
|
||||
const obj = JSON.parse(cleaned.slice(s, e + 1));
|
||||
const rawItems = Array.isArray(obj.tasks) ? obj.tasks : [];
|
||||
items = rawItems
|
||||
.filter((x: unknown) => x && typeof x === 'object')
|
||||
.map((x: Record<string, unknown>) => ({
|
||||
emoji: String(x['emoji'] ?? '📌').slice(0, 8),
|
||||
text: String(x['text'] ?? '').slice(0, 80) || 'Untitled',
|
||||
minutes: Math.max(1, Math.min(60, Number(x['minutes']) || 15)),
|
||||
}))
|
||||
.slice(0, 8);
|
||||
if (!items.length) items = offlineSplit();
|
||||
} catch {
|
||||
items = offlineSplit();
|
||||
}
|
||||
} else {
|
||||
items = offlineSplit();
|
||||
}
|
||||
if (!items.length) throw new Error('No tasks found.');
|
||||
let first = '';
|
||||
for (const it of items) {
|
||||
const plan = heuristicPlan(it.text, 'medium');
|
||||
const id = uid();
|
||||
if (!first) first = id;
|
||||
await db.tasks.add({ id, title: it.text, category: 'Personal', difficulty: 'medium', lifeContext: getLifeContext(), totalMin: plan.totalMin, status: 'planned', createdAt: Date.now() });
|
||||
for (const [i, st] of plan.steps.entries())
|
||||
await db.steps.add({ id: uid(), taskId: id, order: i, emoji: st.emoji, text: st.text, minutes: st.minutes, secondsLeft: st.minutes * 60, status: 'todo' });
|
||||
}
|
||||
setText('');
|
||||
onCreated(first);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Import failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl bg-teal-50 p-4 dark:bg-white/5">
|
||||
<p className="flex items-center gap-1.5 text-[14px] font-bold"><Brain size={16} /> AI Import (Brain Dump)</p>
|
||||
<p className="text-[12px] text-slate-500">Paste chaos. We extract tasks + auto-break each down.</p>
|
||||
<label htmlFor="dump-text" className="sr-only">Brain dump text</label>
|
||||
<textarea id="dump-text" value={text} onChange={(e) => setText(e.target.value)} rows={6}
|
||||
placeholder={'dentist tomorrow, kitchen is chaos, Q3 slides, buy milk…'}
|
||||
className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||
{err && <p className="mt-1 text-[12px] text-red-500">{err}</p>}
|
||||
<button onClick={go} disabled={loading}
|
||||
className="mt-2 min-h-[48px] w-full rounded-xl bg-teal-700 py-2.5 text-[14px] font-bold text-white disabled:opacity-50">
|
||||
{loading ? 'Importing…' : '✨ Import + break down'}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Library ---------------- */
|
||||
function LibraryView({ tasks, onOpen, onReuse }: { tasks: Task[]; onOpen: (t: Task) => void; onReuse: (t: Task) => void }) {
|
||||
if (!tasks.length) return <p className="rounded-2xl bg-slate-50 p-6 text-center text-[13px] text-slate-500 dark:bg-white/5">Library empty. Breakdowns you create are saved here for one-tap reuse. 📚</p>;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2 rounded-2xl border border-black/5 bg-white p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<button onClick={() => onOpen(t)} className="flex-1 text-left">
|
||||
<span className="block text-[14px] font-medium">{CAT_EMOJI[t.category]} {t.title}</span>
|
||||
<span className="text-[12px] text-slate-400">{t.difficulty} · {t.totalMin} min · {new Date(t.createdAt).toLocaleDateString()}</span>
|
||||
</button>
|
||||
<button onClick={() => onReuse(t)} aria-label={`Reuse ${t.title}`} title="Reuse" className="grid min-h-[44px] min-w-[44px] place-items-center rounded-xl bg-teal-50 text-teal-800 dark:bg-white/10 dark:text-teal-200"><Copy size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- AI Edit ---------------- */
|
||||
function AiEditSheet({ task, steps, onClose }: { task: Task; steps: Step[]; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
const [instr, setInstr] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const apply = async () => {
|
||||
if (!instr.trim()) { setErr('Say what to change — e.g. “no vacuum, make steps shorter”.'); return; }
|
||||
setLoading(true); setErr('');
|
||||
const offlineExpand = (): GenPlan => {
|
||||
const longest = [...steps].sort((a, b) => b.minutes - a.minutes)[0];
|
||||
const out = steps.flatMap((s) => (longest && s.id === longest.id
|
||||
? [{ emoji: s.emoji, text: `${s.text} — part 1`, minutes: Math.max(1, Math.ceil(s.minutes / 2)) },
|
||||
{ emoji: '🔍', text: `${s.text} — part 2`, minutes: Math.max(1, Math.floor(s.minutes / 2)) }]
|
||||
: [{ emoji: s.emoji, text: s.text, minutes: s.minutes }]));
|
||||
return { steps: out, totalMin: out.reduce((a, s) => a + s.minutes, 0) };
|
||||
};
|
||||
try {
|
||||
const prior = { steps: steps.map((s) => ({ emoji: s.emoji, text: s.text, minutes: s.minutes })), totalMin: task.totalMin };
|
||||
let plan: GenPlan;
|
||||
if (getKey()) {
|
||||
try {
|
||||
const { plan: p, raw } = await callOpenRouter({
|
||||
system: buildAiEditSystem(task.difficulty, task.lifeContext || getLifeContext()),
|
||||
user: `PRIOR:\n${JSON.stringify(prior)}\n\nCHANGE REQUEST:\n${instr}`,
|
||||
});
|
||||
if (!p) throw new Error(`Invalid JSON. ${raw.slice(0, 150)}`);
|
||||
plan = p;
|
||||
} catch {
|
||||
plan = offlineExpand();
|
||||
}
|
||||
} else {
|
||||
plan = offlineExpand();
|
||||
}
|
||||
await db.steps.where('taskId').equals(task.id).delete();
|
||||
for (const [i, s] of plan.steps.entries())
|
||||
await db.steps.add({ id: uid(), taskId: task.id, order: i, emoji: s.emoji, text: s.text, minutes: s.minutes, secondsLeft: s.minutes * 60, status: 'todo' });
|
||||
await db.tasks.update(task.id, { totalMin: plan.totalMin, status: 'planned' });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Edit failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/50" onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-label={`AI edit ${task.title}`} onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-[480px] rounded-t-3xl bg-white p-4 pb-[max(1rem,env(safe-area-inset-bottom))] dark:bg-[#1b1f27]">
|
||||
<p className="text-[14px] font-bold">🤖 AI Edit — {task.title}</p>
|
||||
<p className="text-[12px] text-slate-600 dark:text-slate-300">overall duration: {task.totalMin} min · try “make it more detailed”, “no oven”, “shorter phases”</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<label className="sr-only" htmlFor="aiedit-input">Describe the change</label>
|
||||
<input id="aiedit-input" value={instr} onChange={(e) => setInstr(e.target.value)} placeholder="Make the last step more detailed…"
|
||||
className="min-h-[44px] flex-1 rounded-xl border border-black/10 bg-slate-50 px-3 py-2.5 text-[16px] outline-none focus:border-teal-600 dark:border-white/10 dark:bg-black/30" />
|
||||
<button onClick={apply} disabled={loading} className="min-h-[44px] rounded-xl bg-teal-700 px-5 text-[13px] font-bold text-white disabled:opacity-50">Send</button>
|
||||
</div>
|
||||
{err && <p className="mt-1 text-[12px] text-red-600 dark:text-red-300">{err}</p>}
|
||||
<button onClick={onClose} className="mt-2 min-h-[44px] w-full rounded-xl bg-slate-100 py-2 text-[13px] font-medium dark:bg-white/10">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Settings ---------------- */
|
||||
function SettingsSheet({ onClose }: { onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
const [key, setKeyState] = useState(getKey());
|
||||
const [model, setModelState] = useState(getModel());
|
||||
const [ctx, setCtx] = useState(getLifeContext());
|
||||
const saved = useRef(false);
|
||||
const save = () => {
|
||||
setKey(key); setModel(model || 'deepseek/deepseek-v4.1-flash'); setLifeContext(ctx);
|
||||
saved.current = true;
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/50" onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Settings" onClick={(e) => e.stopPropagation()}
|
||||
className="nice-scroll max-h-[88dvh] w-full max-w-[480px] overflow-y-auto rounded-t-3xl bg-white p-4 pb-[max(1rem,env(safe-area-inset-bottom))] dark:bg-[#1b1f27]">
|
||||
<p className="text-[15px] font-bold">⚙️ Settings — BYOK + Life Context</p>
|
||||
<label htmlFor="set-key" className="mt-3 block text-[12px] font-semibold text-slate-600 dark:text-slate-300">OPENROUTER API KEY (stored on-device only)</label>
|
||||
<input id="set-key" value={key} onChange={(e) => setKeyState(e.target.value)} type="password" placeholder="sk-or-v1-…"
|
||||
className="mt-1 w-full rounded-xl border border-black/10 bg-slate-50 px-3 py-2.5 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||
<label htmlFor="set-model" className="mt-3 block text-[12px] font-semibold text-slate-600 dark:text-slate-300">MODEL</label>
|
||||
<input id="set-model" value={model} onChange={(e) => setModelState(e.target.value)} placeholder="deepseek/deepseek-v4.1-flash"
|
||||
className="mt-1 w-full rounded-xl border border-black/10 bg-slate-50 px-3 py-2.5 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||
<p className="mt-1 text-[11px] text-slate-400">Default: <code>deepseek/deepseek-v4.1-flash</code> · 1M ctx · structured outputs. Change to any OpenRouter slug.</p>
|
||||
<label htmlFor="set-ctx" className="mt-3 block text-[12px] font-semibold text-slate-600 dark:text-slate-300">LIFE CONTEXT (shapes every breakdown)</label>
|
||||
<textarea id="set-ctx" value={ctx} onChange={(e) => setCtx(e.target.value)} rows={4}
|
||||
placeholder="e.g. small studio, no dishwasher, low energy evenings, easily distracted by phone…"
|
||||
className="mt-1 w-full rounded-xl border border-black/10 bg-slate-50 p-3 text-[16px] outline-none focus:border-teal-400 dark:border-white/10 dark:bg-black/30" />
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button onClick={save} className="min-h-[48px] flex-1 rounded-xl bg-teal-700 py-2.5 text-[14px] font-bold text-white">Save context</button>
|
||||
<button onClick={onClose} className="min-h-[48px] flex-1 rounded-xl bg-slate-100 py-2.5 text-[14px] font-medium dark:bg-white/10">Close</button>
|
||||
</div>
|
||||
<button onClick={async () => { if (confirm('Delete all local tasks?')) { await db.tasks.clear(); await db.steps.clear(); onClose(); } }}
|
||||
className="mt-2 min-h-[44px] w-full rounded-xl border border-red-200 py-2 text-[13px] font-medium text-red-600 dark:text-red-300">Delete all local data</button>
|
||||
<p className="mt-2 text-center text-[11px] text-slate-400">FOSS · MIT · keys never leave your device except to api.openrouter.ai · <Home size={0} /></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { heuristicPlan } from './lib/fallback';
|
||||
import { parsePlanExported } from './lib/openrouter';
|
||||
import { buildSystemPrompt } from './lib/prompts';
|
||||
|
||||
describe('heuristicPlan', () => {
|
||||
it('kitchen hard sums correctly', () => {
|
||||
const p = heuristicPlan('The kitchen has descended into chaos', 'hard');
|
||||
expect(p.steps.length).toBeGreaterThanOrEqual(8);
|
||||
expect(p.totalMin).toBe(p.steps.reduce((a, s) => a + s.minutes, 0));
|
||||
});
|
||||
it('generic easy is short', () => {
|
||||
const p = heuristicPlan('do something vague', 'easy');
|
||||
expect(p.steps.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompts', () => {
|
||||
it('embeds difficulty + context', () => {
|
||||
const s = buildSystemPrompt('hard', 'small studio, low energy');
|
||||
expect(s).toMatch(/hyper-granular/i);
|
||||
expect(s).toMatch(/small studio/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlan', () => {
|
||||
it('parses fenced json', () => {
|
||||
const raw = '```json\n{"steps":[{"emoji":"🧽","text":"Wipe counters","minutes":4}],"totalMin":4}\n```';
|
||||
const p = parsePlanExported(raw);
|
||||
expect(p?.steps[0].text).toMatch(/Wipe/);
|
||||
expect(p?.totalMin).toBe(4);
|
||||
});
|
||||
it('rejects garbage', () => {
|
||||
expect(parsePlanExported('hello world')).toBeNull();
|
||||
});
|
||||
it('reconciles mismatched totalMin to sum of steps', () => {
|
||||
const raw = '{"steps":[{"emoji":"a","text":"one","minutes":10},{"emoji":"b","text":"two","minutes":30}],"totalMin":5}';
|
||||
const p = parsePlanExported(raw);
|
||||
expect(p?.totalMin).toBe(40);
|
||||
});
|
||||
it('clamps minutes to 1..60 and caps 20 steps', () => {
|
||||
const steps = Array.from({ length: 25 }, (_, i) => ({ emoji: 'x', text: `s${i}`, minutes: 999 }));
|
||||
const p = parsePlanExported(JSON.stringify({ steps, totalMin: 1 }));
|
||||
expect(p?.steps.length).toBe(20);
|
||||
expect(p?.steps[0].minutes).toBe(60);
|
||||
expect(p?.totalMin).toBe(20 * 60);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,35 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--calm: #14b8a6;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
html, body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* calm focus card animation */
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.015); }
|
||||
}
|
||||
.focus-breathe { animation: none; }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.focus-breathe { animation: breathe 6s ease-in-out infinite; }
|
||||
}
|
||||
|
||||
.timer-ring { transition: stroke-dashoffset 0.5s linear; }
|
||||
|
||||
/* thin scrollbars for step lists */
|
||||
.nice-scroll::-webkit-scrollbar { width: 6px; }
|
||||
.nice-scroll::-webkit-scrollbar-thumb { background: rgba(20,184,166,.4); border-radius: 999px; }
|
||||
|
||||
:focus-visible { outline: 2px solid #0f766e; outline-offset: 2px; }
|
||||
input:focus, textarea:focus { outline: 2px solid #0f766e; outline-offset: 1px; }
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { GenPlan } from './openrouter';
|
||||
|
||||
// Offline heuristic fallback when no BYOK key: deterministic micro-breakdown
|
||||
// so timer/library/focus can be demoed without API.
|
||||
export function heuristicPlan(title: string, difficulty: 'easy' | 'medium' | 'hard'): GenPlan {
|
||||
const t = title.toLowerCase();
|
||||
const isKitchen = /kitchen|clean|room| dishes|laundry|apartment/.test(t);
|
||||
const isFood = /cook|lunch|meal|egg|eat|dinner|breakfast/.test(t);
|
||||
const isWork = /report|review|q3|marketing|write|deck|email|analy/.test(t);
|
||||
|
||||
if (isKitchen) {
|
||||
const steps = [
|
||||
{ emoji: '🎧', text: 'Put on music, gloves, grab trash bag', minutes: 2 },
|
||||
{ emoji: '🗑️', text: 'Tie trash, take to door, reliner bin', minutes: 3 },
|
||||
{ emoji: '👀', text: 'Perimeter walk: toss visible wrappers', minutes: 4 },
|
||||
{ emoji: '☕', text: 'Group mugs and glasses by sink', minutes: 3 },
|
||||
{ emoji: '🍽️', text: 'Stack plates and cutlery neatly', minutes: 3 },
|
||||
{ emoji: '🧽', text: 'Scrape food, soak worst items', minutes: 4 },
|
||||
{ emoji: '🫧', text: 'Wash glasses + cutlery to mat', minutes: 6 },
|
||||
{ emoji: '🍳', text: 'Wash plates + big bowls to rack', minutes: 5 },
|
||||
{ emoji: '✨', text: 'Spray + wipe counters and table', minutes: 4 },
|
||||
{ emoji: '💧', text: 'Gloves off, water, tap complete', minutes: 3 },
|
||||
];
|
||||
const sliced = difficulty === 'easy' ? [steps[2], steps[6], steps[8], steps[9]] : difficulty === 'medium' ? steps.slice(1, 8) : steps;
|
||||
return { steps: sliced, totalMin: sliced.reduce((a, s) => a + s.minutes, 0) };
|
||||
}
|
||||
if (isWork) {
|
||||
const steps = [
|
||||
{ emoji: '📄', text: 'Open template, save file immediately', minutes: 3 },
|
||||
{ emoji: '📊', text: 'Export raw metrics to working folder', minutes: 10 },
|
||||
{ emoji: '🎯', text: 'Extract top 3 metrics to notes', minutes: 12 },
|
||||
{ emoji: '🏆', text: 'Draft 3 bullets: quarterly wins', minutes: 15 },
|
||||
{ emoji: '🔧', text: 'Draft 2 bullets: fixes + next steps', minutes: 15 },
|
||||
{ emoji: '📈', text: 'Add clean charts to slides 2-3', minutes: 10 },
|
||||
{ emoji: '📤', text: 'Proof once, export PDF, draft email', minutes: 10 },
|
||||
];
|
||||
const sliced = difficulty === 'hard' ? steps.flatMap((s) => s.minutes > 10 ? [
|
||||
{ ...s, text: s.text + ' — part 1 (gather)', minutes: Math.ceil(s.minutes / 2) },
|
||||
{ ...s, text: s.text + ' — part 2 (finish)', minutes: Math.floor(s.minutes / 2) },
|
||||
] : [s]) : difficulty === 'easy' ? [steps[0], { emoji: '🧱', text: 'Draft all bullets in one pass', minutes: 20 }, steps[5], steps[6]] : steps;
|
||||
return { steps: sliced.slice(0, 12), totalMin: sliced.slice(0, 12).reduce((a, s) => a + s.minutes, 0) };
|
||||
}
|
||||
if (isFood) {
|
||||
const steps = [
|
||||
{ emoji: '🧹', text: 'Clear counter, set board + knife', minutes: 2 },
|
||||
{ emoji: '🥚', text: 'Get eggs, butter, bread, tomatoes', minutes: 2 },
|
||||
{ emoji: '🍳', text: 'Crack + whisk eggs with salt', minutes: 2 },
|
||||
{ emoji: '🍅', text: 'Halve tomatoes, set aside', minutes: 2 },
|
||||
{ emoji: '🧈', text: 'Pan on medium-low + butter', minutes: 2 },
|
||||
{ emoji: '🍞', text: 'Start toast now', minutes: 1 },
|
||||
{ emoji: '🔥', text: 'Pour eggs, fold gently till set', minutes: 4 },
|
||||
{ emoji: '🍽️', text: 'Plate eggs + toast + tomatoes', minutes: 2 },
|
||||
{ emoji: '🚰', text: 'Burner off, pan to soak', minutes: 2 },
|
||||
{ emoji: '😌', text: 'Sit, water, eat calmly', minutes: 1 },
|
||||
];
|
||||
const sliced = difficulty === 'easy' ? [steps[1], { emoji: '👨🍳', text: 'Cook everything in one go', minutes: 10 }, steps[7], steps[9]] : steps;
|
||||
return { steps: sliced, totalMin: sliced.reduce((a, s) => a + s.minutes, 0) };
|
||||
}
|
||||
// generic
|
||||
const base = [
|
||||
{ emoji: '🎯', text: `Set up space for: ${title.slice(0, 40)}`, minutes: 2 },
|
||||
{ emoji: '🧠', text: 'Dump all sub-parts on paper quickly', minutes: 3 },
|
||||
{ emoji: '✂️', text: 'Do smallest visible piece first', minutes: 5 },
|
||||
{ emoji: '🧱', text: 'Do main middle chunk, no polish', minutes: 10 },
|
||||
{ emoji: '🧹', text: 'Tidy + finish last 10%', minutes: 5 },
|
||||
{ emoji: '💧', text: 'Water, breathe, mark complete', minutes: 2 },
|
||||
];
|
||||
const steps = difficulty === 'easy' ? [base[0], base[3], base[5]] : difficulty === 'medium' ? base.slice(0, 5).concat(base[5]) : [
|
||||
{ emoji: '🎧', text: 'Starter: music + clear 1 surface', minutes: 2 },
|
||||
...base.slice(1, 5).flatMap((s) => [{ ...s, minutes: Math.max(2, Math.ceil(s.minutes / 2)) }]),
|
||||
base[5],
|
||||
];
|
||||
return { steps, totalMin: steps.reduce((a, s) => a + s.minutes, 0) };
|
||||
}
|
||||
|
||||
export function speak(text: string, enabled: boolean) {
|
||||
try {
|
||||
if (!enabled || !('speechSynthesis' in window)) return;
|
||||
window.speechSynthesis.cancel();
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.rate = 1.02;
|
||||
window.speechSynthesis.speak(u);
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
export function beep() {
|
||||
try {
|
||||
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
const ctx = new Ctx();
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
o.frequency.value = 880; g.gain.value = 0.08;
|
||||
o.start(); o.stop(ctx.currentTime + 0.25);
|
||||
setTimeout(() => ctx.close(), 400);
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export const DEFAULT_MODEL = 'deepseek/deepseek-v4.1-flash';
|
||||
|
||||
export const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface GenStep {
|
||||
emoji: string;
|
||||
text: string;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export interface GenPlan {
|
||||
steps: GenStep[];
|
||||
totalMin: number;
|
||||
}
|
||||
|
||||
export function getKey(): string {
|
||||
return localStorage.getItem('mindlist.or.key') ?? '';
|
||||
}
|
||||
export function getModel(): string {
|
||||
return localStorage.getItem('mindlist.or.model') || DEFAULT_MODEL;
|
||||
}
|
||||
export function setKey(k: string) {
|
||||
if (k) localStorage.setItem('mindlist.or.key', k.trim());
|
||||
else localStorage.removeItem('mindlist.or.key');
|
||||
}
|
||||
export function setModel(m: string) {
|
||||
localStorage.setItem('mindlist.or.model', m.trim() || DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
function safeParsePlan(raw: string): GenPlan | null {
|
||||
try {
|
||||
// strip code fences
|
||||
const cleaned = raw.replace(/```json|```/g, '').trim();
|
||||
const start = cleaned.indexOf('{');
|
||||
const end = cleaned.lastIndexOf('}');
|
||||
if (start < 0 || end < 0) return null;
|
||||
const obj = JSON.parse(cleaned.slice(start, end + 1));
|
||||
if (!Array.isArray(obj.steps)) return null;
|
||||
const steps: GenStep[] = obj.steps
|
||||
.filter((s: unknown) => s && typeof s === 'object')
|
||||
.map((s: Record<string, unknown>) => ({
|
||||
emoji: String(s['emoji'] ?? '✅').slice(0, 8),
|
||||
text: String(s['text'] ?? s['title'] ?? 'Step').slice(0, 160),
|
||||
minutes: Math.max(1, Math.min(60, Math.round(Number(s['minutes'] ?? s['duration'] ?? 3)) || 3)),
|
||||
}))
|
||||
.slice(0, 20);
|
||||
if (steps.length === 0) return null;
|
||||
const sum = steps.reduce((a, s) => a + s.minutes, 0);
|
||||
// Reconcile: trust the sum of steps (fixes AI header/sum mismatch)
|
||||
const totalMin = sum;
|
||||
return { steps, totalMin };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOpenRouter(opts: {
|
||||
system: string;
|
||||
user: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ plan: GenPlan | null; raw: string }> {
|
||||
const key = getKey();
|
||||
if (!key) throw new Error('Missing OpenRouter API key. Add it in Settings (BYOK).');
|
||||
const model = getModel();
|
||||
const res = await fetch(OPENROUTER_URL, {
|
||||
method: 'POST',
|
||||
signal: opts.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': window.location.origin,
|
||||
'X-Title': 'Mindlist FOSS',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: opts.system },
|
||||
{ role: 'user', content: opts.user },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
max_tokens: 2000,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => '');
|
||||
throw new Error(`OpenRouter ${res.status}: ${t.slice(0, 300)}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
const content: string = json?.choices?.[0]?.message?.content ?? '';
|
||||
return { plan: safeParsePlan(content), raw: content };
|
||||
}
|
||||
|
||||
export function parsePlanExported(raw: string): GenPlan | null {
|
||||
return safeParsePlan(raw);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
export const DIFFICULTY_HELP: Record<Difficulty, string> = {
|
||||
easy: 'High-level phases. For when you know how, just need milestones.',
|
||||
medium: 'Standard procedural workflow with clear boundaries.',
|
||||
hard: 'Hyper-granular physical micro-moves. Zero planning needed per step.',
|
||||
};
|
||||
|
||||
export function buildSystemPrompt(difficulty: Difficulty, lifeContext: string): string {
|
||||
const depth =
|
||||
difficulty === 'easy'
|
||||
? 'Generate 3-5 high-level phases.'
|
||||
: difficulty === 'medium'
|
||||
? 'Generate 5-8 concrete procedural steps with tight boundaries to avoid rabbit holes.'
|
||||
: 'Generate 8-12 hyper-granular PHYSICAL micro-actions. Each step must be one unambiguous bodily movement with no implicit knowledge. Start with a 1-2 min sensory starter (music, gloves, clear surface). End with a reset step (water, breathe, complete).';
|
||||
return `You are Mindlist, a cognitive prosthetic for ADHD executive dysfunction.
|
||||
Break the user's daunting task into an executable linear playlist.
|
||||
RULES:
|
||||
- ${depth}
|
||||
- Every step: {"emoji": single emoji, "text": imperative micro-action <=14 words, "minutes": 1-15 integer}
|
||||
- Total time realistic. Sum of minutes = totalMin.
|
||||
- Calm, shame-free tone. No jargon.
|
||||
- Respect Life Context constraints strictly (space, tools, energy, distractions).
|
||||
- Output ONLY valid JSON: {"steps":[...], "totalMin": number}
|
||||
Life Context: ${lifeContext?.trim() ? lifeContext.slice(0, 800) : '(none provided)'}
|
||||
Difficulty: ${difficulty}`;
|
||||
}
|
||||
|
||||
export function buildBrainDumpSystem(lifeContext: string): string {
|
||||
return `You are Mindlist AI Import. Extract distinct actionable tasks from chaotic brain-dump text.
|
||||
Rules: split into 1-8 tasks, each {"emoji": emoji, "text": clear task title <=10 words, "minutes": rough guess}.
|
||||
Respect Life Context: ${lifeContext?.slice(0, 500) || '(none)'}
|
||||
Output ONLY JSON: {"tasks": [...]}`;
|
||||
}
|
||||
|
||||
export function buildAiEditSystem(difficulty: Difficulty, lifeContext: string): string {
|
||||
return `You are Mindlist AI Edit. User gives a prior plan JSON + natural-language change request (missing tool, shorter phases, expand a hard step, less energy, etc).
|
||||
Return a FULL updated plan JSON only: {"steps":[{"emoji","text","minutes"}], "totalMin": number}.
|
||||
Keep difficulty=${difficulty} granularity unless asked otherwise. Respect Life Context: ${lifeContext?.slice(0, 600) || '(none)'}.`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { Difficulty } from './prompts';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
difficulty: Difficulty;
|
||||
lifeContext: string;
|
||||
totalMin: number;
|
||||
status: 'planned' | 'active' | 'done';
|
||||
createdAt: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
id: string;
|
||||
taskId: string;
|
||||
order: number;
|
||||
emoji: string;
|
||||
text: string;
|
||||
minutes: number;
|
||||
secondsLeft: number;
|
||||
status: 'todo' | 'active' | 'done' | 'skipped';
|
||||
}
|
||||
|
||||
class DB extends Dexie {
|
||||
tasks!: Table<Task, string>;
|
||||
steps!: Table<Step, string>;
|
||||
constructor() {
|
||||
super('mindlist');
|
||||
this.version(1).stores({ tasks: 'id, status, createdAt', steps: 'id, taskId, order' });
|
||||
}
|
||||
}
|
||||
export const db = new DB();
|
||||
|
||||
export const uid = () =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
export function getLifeContext(): string {
|
||||
return localStorage.getItem('mindlist.context') ?? '';
|
||||
}
|
||||
export function setLifeContext(v: string) {
|
||||
localStorage.setItem('mindlist.context', v);
|
||||
}
|
||||
export function getVoiceOn(): boolean {
|
||||
return (localStorage.getItem('mindlist.voice') ?? 'on') === 'on';
|
||||
}
|
||||
export function setVoiceOn(v: boolean) {
|
||||
localStorage.setItem('mindlist.voice', v ? 'on' : 'off');
|
||||
}
|
||||
export function getDark(): boolean {
|
||||
if (typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||
return true;
|
||||
return localStorage.getItem('mindlist.dark') === 'on';
|
||||
}
|
||||
export function setDark(v: boolean) {
|
||||
localStorage.setItem('mindlist.dark', v ? 'on' : 'off');
|
||||
document.documentElement.classList.toggle('dark', v);
|
||||
}
|
||||
|
||||
function icsEscape(s: string): string {
|
||||
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
export function downloadICS(task: Task, steps: Step[]) {
|
||||
const dt = new Date();
|
||||
const fmt = (d: Date) =>
|
||||
d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
let cursor = new Date(dt.getTime() + 5 * 60000);
|
||||
const live = steps.filter((s) => s.status !== 'skipped');
|
||||
const events = live
|
||||
.map((s) => {
|
||||
const mins = Math.max(1, Math.ceil(s.secondsLeft / 60) || s.minutes);
|
||||
const start = new Date(cursor);
|
||||
const end = new Date(cursor.getTime() + mins * 60000);
|
||||
cursor = end;
|
||||
const summary = icsEscape(`${s.emoji} ${s.text.replace(/\n/g, ' ')}`);
|
||||
return `BEGIN:VEVENT\nUID:${s.id}@mindlist\nDTSTAMP:${fmt(dt)}\nDTSTART:${fmt(start)}\nDTEND:${fmt(end)}\nSUMMARY:${summary}\nDESCRIPTION:${icsEscape(`Part of Mindlist task: ${task.title}`)}\nBEGIN:VALARM\nTRIGGER:-PT2M\nACTION:DISPLAY\nDESCRIPTION:${summary}\nEND:VALARM\nEND:VEVENT`;
|
||||
})
|
||||
.join('\n');
|
||||
const ics = `BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Mindlist//FOSS//EN\n${events}\nEND:VCALENDAR`;
|
||||
const blob = new Blob([ics], { type: 'text/calendar' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `mindlist-${task.title.slice(0, 30).replace(/\s+/g, '-')}.ics`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Reference in new issue
Block a user