This commit is contained in:
1 parent
bbfcbe74af
commit
4266958814
9 files changed
+426
-50
No files matched your search
@@ -12,6 +12,7 @@ dependencies {
|
|||||||
implementation project(':capacitor-community-text-to-speech')
|
implementation project(':capacitor-community-text-to-speech')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
implementation project(':capacitor-local-notifications')
|
implementation project(':capacitor-local-notifications')
|
||||||
|
implementation project(':capacitor-share')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
|||||||
|
|
||||||
include ':capacitor-local-notifications'
|
include ':capacitor-local-notifications'
|
||||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||||
|
|
||||||
|
include ':capacitor-share'
|
||||||
|
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||||
Generated
+10
@@ -14,6 +14,7 @@
|
|||||||
"@capacitor/cli": "^8.5.2",
|
"@capacitor/cli": "^8.5.2",
|
||||||
"@capacitor/core": "^8.5.2",
|
"@capacitor/core": "^8.5.2",
|
||||||
"@capacitor/local-notifications": "^8.3.1",
|
"@capacitor/local-notifications": "^8.3.1",
|
||||||
|
"@capacitor/share": "^8.0.2",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"dexie": "^4.4.6",
|
"dexie": "^4.4.6",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
@@ -1615,6 +1616,15 @@
|
|||||||
"@capacitor/core": ">=8.0.0"
|
"@capacitor/core": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/share": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-L2M4iBLTf/IaMmg+ruuriAoJyBzu1EgayWhgC5cX1HJGkA665g2ROz8c2Z5lfkT/XSLbAuG3bb7VlW3eGgUCNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ionic/cli-framework-output": {
|
"node_modules/@ionic/cli-framework-output": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@capacitor/cli": "^8.5.2",
|
"@capacitor/cli": "^8.5.2",
|
||||||
"@capacitor/core": "^8.5.2",
|
"@capacitor/core": "^8.5.2",
|
||||||
"@capacitor/local-notifications": "^8.3.1",
|
"@capacitor/local-notifications": "^8.3.1",
|
||||||
|
"@capacitor/share": "^8.0.2",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"dexie": "^4.4.6",
|
"dexie": "^4.4.6",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
|||||||
+129
-42
@@ -14,7 +14,6 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
Settings2,
|
Settings2,
|
||||||
Sparkles,
|
|
||||||
Sun,
|
Sun,
|
||||||
Trash2,
|
Trash2,
|
||||||
Volume2,
|
Volume2,
|
||||||
@@ -25,6 +24,7 @@ import { App as CapApp } from '@capacitor/app';
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { beep, speak, stopSpeak } from './lib/audio';
|
import { beep, speak, stopSpeak } from './lib/audio';
|
||||||
import { buildAlarms, catchUp, WARN_BEFORE_MS, type Alarm } from './lib/catchup';
|
import { buildAlarms, catchUp, WARN_BEFORE_MS, type Alarm } from './lib/catchup';
|
||||||
|
import { reconcilePlan } from './lib/reconcile';
|
||||||
import { heuristicPlan } from './lib/fallback';
|
import { heuristicPlan } from './lib/fallback';
|
||||||
import { fgStart, fgStop, fgUpdate } from './lib/fgservice';
|
import { fgStart, fgStop, fgUpdate } from './lib/fgservice';
|
||||||
import {
|
import {
|
||||||
@@ -46,13 +46,19 @@ import {
|
|||||||
import { buildAiEditSystem, buildBrainDumpSystem, buildSystemPrompt, type Difficulty } from './lib/prompts';
|
import { buildAiEditSystem, buildBrainDumpSystem, buildSystemPrompt, type Difficulty } from './lib/prompts';
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
|
buildICS,
|
||||||
|
dateChip,
|
||||||
|
dayStart,
|
||||||
downloadICS,
|
downloadICS,
|
||||||
|
fromISODate,
|
||||||
getDark,
|
getDark,
|
||||||
getLifeContext,
|
getLifeContext,
|
||||||
getVoiceOn,
|
getVoiceOn,
|
||||||
|
isDue,
|
||||||
setDark,
|
setDark,
|
||||||
setLifeContext,
|
setLifeContext,
|
||||||
setVoiceOn,
|
setVoiceOn,
|
||||||
|
toISODate,
|
||||||
uid,
|
uid,
|
||||||
type Step,
|
type Step,
|
||||||
type Task,
|
type Task,
|
||||||
@@ -81,6 +87,13 @@ export default function App() {
|
|||||||
const [showAiEdit, setShowAiEdit] = useState(false);
|
const [showAiEdit, setShowAiEdit] = useState(false);
|
||||||
const [dark, setDarkState] = useState(getDark());
|
const [dark, setDarkState] = useState(getDark());
|
||||||
const [voice, setVoice] = useState(getVoiceOn());
|
const [voice, setVoice] = useState(getVoiceOn());
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
const toastTimer = useRef<number | null>(null);
|
||||||
|
const notify = useCallback((msg: string) => {
|
||||||
|
setToast(msg);
|
||||||
|
if (toastTimer.current) window.clearTimeout(toastTimer.current);
|
||||||
|
toastTimer.current = window.setTimeout(() => setToast(null), 4000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.toggle('dark', dark);
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
@@ -94,7 +107,9 @@ export default function App() {
|
|||||||
[activeTask?.id],
|
[activeTask?.id],
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
const doneCount = tasks.filter((t) => t.status === 'done').length;
|
const doneToday = tasks.filter(
|
||||||
|
(t) => t.status === 'done' && (t.completedAt ?? 0) >= dayStart(),
|
||||||
|
).length;
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => (catFilter === 'All' ? tasks : tasks.filter((t) => t.category === catFilter)),
|
() => (catFilter === 'All' ? tasks : tasks.filter((t) => t.category === catFilter)),
|
||||||
[tasks, catFilter],
|
[tasks, catFilter],
|
||||||
@@ -102,7 +117,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[#f6f7f7] text-slate-800 dark:bg-[#0f1115] dark:text-slate-100">
|
<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]">
|
<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] landscape:max-w-[760px]">
|
||||||
{/* header */}
|
{/* 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">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
@@ -134,15 +149,16 @@ export default function App() {
|
|||||||
|
|
||||||
<main className="flex-1 px-4 pb-28">
|
<main className="flex-1 px-4 pb-28">
|
||||||
{view === 'today' && (
|
{view === 'today' && (
|
||||||
<TodayView tasks={filtered} doneCount={doneCount} activeSteps={activeSteps}
|
<TodayView tasks={filtered} doneCount={doneToday}
|
||||||
onOpen={(t) => { setActiveTaskId(t.id); setView('new'); }}
|
onOpen={(t) => { setActiveTaskId(t.id); setView('new'); }}
|
||||||
onFocus={(t) => { setActiveTaskId(t.id); setFocusMode(true); }} />
|
onFocus={(t) => { setActiveTaskId(t.id); setFocusMode(true); }} />
|
||||||
)}
|
)}
|
||||||
{view === 'new' && (
|
{view === 'new' && (
|
||||||
<ComposerView activeTask={activeTask} activeSteps={activeSteps}
|
<ComposerView activeTask={activeTask} activeSteps={activeSteps} showDetail={activeTaskId !== null}
|
||||||
onCreated={(id) => setActiveTaskId(id)}
|
onCreated={(id) => setActiveTaskId(id)}
|
||||||
onFocus={() => setFocusMode(true)}
|
onFocus={() => setFocusMode(true)}
|
||||||
onEdit={() => setShowAiEdit(true)} />
|
onEdit={() => setShowAiEdit(true)}
|
||||||
|
notify={notify} />
|
||||||
)}
|
)}
|
||||||
{view === 'dump' && <DumpView onCreated={(id) => { setActiveTaskId(id); setView('new'); }} />}
|
{view === 'dump' && <DumpView onCreated={(id) => { setActiveTaskId(id); setView('new'); }} />}
|
||||||
{view === 'library' && (
|
{view === 'library' && (
|
||||||
@@ -151,23 +167,25 @@ export default function App() {
|
|||||||
onReuse={async (t) => {
|
onReuse={async (t) => {
|
||||||
const steps = await db.steps.where('taskId').equals(t.id).sortBy('order');
|
const steps = await db.steps.where('taskId').equals(t.id).sortBy('order');
|
||||||
const nid = uid();
|
const nid = uid();
|
||||||
await db.tasks.add({ ...t, id: nid, status: 'planned', createdAt: Date.now(), completedAt: undefined });
|
await db.tasks.add({ ...t, id: nid, status: 'planned', createdAt: Date.now(), completedAt: undefined, scheduledAt: dayStart() });
|
||||||
for (const [i, s] of steps.entries())
|
for (const [i, s] of steps.entries())
|
||||||
await db.steps.add({ ...s, id: uid(), taskId: nid, order: i, status: 'todo', secondsLeft: s.minutes * 60 });
|
await db.steps.add({ ...s, id: uid(), taskId: nid, order: i, status: 'todo', secondsLeft: s.minutes * 60, endsAt: undefined });
|
||||||
setActiveTaskId(nid); setView('new');
|
setActiveTaskId(nid); setView('new');
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* bottom nav */}
|
{/* 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">
|
<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 landscape:max-w-[760px] landscape:px-16">
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid grid-cols-4 place-items-center" role="navigation" aria-label="Primary">
|
||||||
<NavBtn icon={<Home size={20} />} label="Today" active={view === 'today'} onClick={() => setView('today')} />
|
<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')} />
|
<NavBtn icon={<ListTodo size={20} />} label="Library" active={view === 'library'} onClick={() => setView('library')} />
|
||||||
|
<button onClick={() => { setActiveTaskId(null); setView('new'); }} aria-label="Create new task" aria-current={view === 'new' ? 'page' : undefined}
|
||||||
|
className="flex min-h-[48px] min-w-[56px] flex-col items-center justify-center gap-0.5 rounded-2xl">
|
||||||
|
<span className="grid size-11 place-items-center rounded-full bg-teal-700 text-white"><Plus size={22} /></span>
|
||||||
|
<span className="text-[11px] font-semibold text-slate-500 dark:text-slate-400">New</span>
|
||||||
|
</button>
|
||||||
|
<NavBtn icon={<Brain size={20} />} label="Dump" active={view === 'dump'} onClick={() => setView('dump')} />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -178,6 +196,12 @@ export default function App() {
|
|||||||
<AiEditSheet task={activeTask} steps={activeSteps} onClose={() => setShowAiEdit(false)} />
|
<AiEditSheet task={activeTask} steps={activeSteps} onClose={() => setShowAiEdit(false)} />
|
||||||
)}
|
)}
|
||||||
{showSettings && <SettingsSheet onClose={() => setShowSettings(false)} />}
|
{showSettings && <SettingsSheet onClose={() => setShowSettings(false)} />}
|
||||||
|
{toast && (
|
||||||
|
<button onClick={() => setToast(null)} role="status"
|
||||||
|
className="fixed bottom-24 left-1/2 z-50 w-max max-w-[90dvw] -translate-x-1/2 rounded-xl bg-slate-900 px-4 py-2.5 text-center text-[13px] font-medium text-white shadow-xl dark:bg-white dark:text-slate-900">
|
||||||
|
{toast}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -192,43 +216,55 @@ function NavBtn({ icon, label, active, onClick }: { icon: React.ReactNode; label
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Today ---------------- */
|
/* ---------------- Today ---------------- */
|
||||||
function TodayView({ tasks, doneCount, activeSteps, onOpen, onFocus }: {
|
function TodayView({ tasks, doneCount, onOpen, onFocus }: {
|
||||||
tasks: Task[]; doneCount: number; activeSteps: Step[];
|
tasks: Task[]; doneCount: number;
|
||||||
onOpen: (t: Task) => void; onFocus: (t: Task) => void;
|
onOpen: (t: Task) => void; onFocus: (t: Task) => void;
|
||||||
}) {
|
}) {
|
||||||
const today = new Date().toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
const today = new Date().toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||||
const active = tasks.find((t) => t.status !== 'done');
|
const due = tasks.filter((t) => isDue(t));
|
||||||
|
const active = due.find((t) => t.status !== 'done');
|
||||||
|
// Hero progress always belongs to the hero task (not the composer's fallback).
|
||||||
|
const heroSteps = useLiveQuery(
|
||||||
|
() => (active ? db.steps.where('taskId').equals(active.id).sortBy('order') : Promise.resolve([] as Step[])),
|
||||||
|
[active?.id],
|
||||||
|
) ?? [];
|
||||||
|
const heroDone = heroSteps.filter((s) => s.status === 'done').length;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-2xl bg-teal-50 p-4 dark:bg-white/5">
|
<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>
|
<p className="text-[13px] text-slate-500 dark:text-slate-400">📅 {today} · 🎉 {doneCount} done today</p>
|
||||||
{active ? (
|
{active ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-[15px] font-semibold">▶ {active.title}</p>
|
<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>
|
<p className="text-[12px] text-slate-500">{heroDone}/{heroSteps.length} steps · ~{active.totalMin} min</p>
|
||||||
<div className="mt-2 flex gap-2">
|
<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={() => 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>
|
<button onClick={() => onOpen(active)} className="min-h-[44px] 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>
|
||||||
</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>
|
<p className="mt-2 text-[14px]">Nothing scheduled today. Tap <b>+</b> or <b>Dump</b> to break a scary task down. 🌱</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tasks.map((t) => (
|
{due.map((t) => {
|
||||||
|
const chip = dateChip(t);
|
||||||
|
const overdue = chip === 'Overdue';
|
||||||
|
return (
|
||||||
<button key={t.id} onClick={() => onOpen(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">
|
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="text-xl" aria-hidden="true">{CAT_EMOJI[t.category] ?? '✅'}</span>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
<span className={`block text-[14px] font-medium ${t.status === 'done' ? 'line-through opacity-60' : ''}`}>{t.title}</span>
|
<span className="block text-[14px] font-medium">{t.title}</span>
|
||||||
<span className="text-[12px] text-slate-500">{t.difficulty} · {t.totalMin} min · {t.status}</span>
|
<span className="text-[12px] text-slate-500">{t.difficulty} · {t.totalMin} min</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span className={`shrink-0 rounded-full px-2 py-1 text-[12px] font-semibold ${overdue ? 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-200' : 'bg-teal-50 text-teal-900 dark:bg-teal-950/70 dark:text-teal-100 dark:ring-1 dark:ring-teal-400/40'}`}>{chip}</span>
|
||||||
<ChevronRight size={16} className="text-slate-300" />
|
<ChevronRight size={16} className="text-slate-300" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
{tasks.length === 0 && (
|
})}
|
||||||
|
{due.length === 0 && (
|
||||||
<div className="rounded-2xl border border-dashed border-teal-300 p-6 text-center text-[13px] text-slate-500">
|
<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.
|
🧠 All clear for today.<br />Future tasks live under their date — everything reusable lives in 📚 Library.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -236,13 +272,14 @@ function TodayView({ tasks, doneCount, activeSteps, onOpen, onFocus }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Composer ---------------- */
|
/* ---------------- Composer ---------------- */
|
||||||
function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: {
|
function ComposerView({ activeTask, activeSteps, showDetail, onCreated, onFocus, onEdit, notify }: {
|
||||||
activeTask: Task | null; activeSteps: Step[];
|
activeTask: Task | null; activeSteps: Step[]; showDetail: boolean;
|
||||||
onCreated: (id: string) => void; onFocus: () => void; onEdit: () => void;
|
onCreated: (id: string) => void; onFocus: () => void; onEdit: () => void; notify: (msg: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [category, setCategory] = useState<string>('Home');
|
const [category, setCategory] = useState<string>('Home');
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>('medium');
|
const [difficulty, setDifficulty] = useState<Difficulty>('medium');
|
||||||
|
const [date, setDate] = useState(() => toISODate());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [notice, setNotice] = useState('');
|
const [notice, setNotice] = useState('');
|
||||||
@@ -267,12 +304,13 @@ function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: {
|
|||||||
} else {
|
} else {
|
||||||
plan = heuristicPlan(t, difficulty); // offline demo mode
|
plan = heuristicPlan(t, difficulty); // offline demo mode
|
||||||
}
|
}
|
||||||
|
const scheduledAt = date ? (fromISODate(date) ?? undefined) : undefined;
|
||||||
const id = activeTask && activeTask.title === t ? activeTask.id : uid();
|
const id = activeTask && activeTask.title === t ? activeTask.id : uid();
|
||||||
if (activeTask && activeTask.title === t) {
|
if (activeTask && activeTask.title === t) {
|
||||||
await db.steps.where('taskId').equals(id).delete();
|
await db.steps.where('taskId').equals(id).delete();
|
||||||
await db.tasks.update(id, { difficulty, category, totalMin: plan.totalMin, status: 'planned', lifeContext: ctx });
|
await db.tasks.update(id, { difficulty, category, totalMin: plan.totalMin, status: 'planned', lifeContext: ctx, scheduledAt });
|
||||||
} else {
|
} else {
|
||||||
await db.tasks.add({ id, title: t, category, difficulty, lifeContext: ctx, totalMin: plan.totalMin, status: 'planned', createdAt: Date.now() });
|
await db.tasks.add({ id, title: t, category, difficulty, lifeContext: ctx, totalMin: plan.totalMin, status: 'planned', createdAt: Date.now(), scheduledAt });
|
||||||
}
|
}
|
||||||
for (const [i, s] of plan.steps.entries())
|
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' });
|
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' });
|
||||||
@@ -283,7 +321,7 @@ function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [title, activeTask, category, difficulty, onCreated]);
|
}, [title, activeTask, category, difficulty, date, onCreated]);
|
||||||
|
|
||||||
const toggleStep = async (s: Step) => {
|
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 });
|
await db.steps.update(s.id, { status: s.status === 'done' ? 'todo' : 'done', secondsLeft: s.status === 'done' ? s.minutes * 60 : 0 });
|
||||||
@@ -315,11 +353,18 @@ function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: {
|
|||||||
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>
|
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>
|
</fieldset>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<label htmlFor="task-date" className="shrink-0 text-[12px] font-semibold text-slate-500 dark:text-slate-300">📅 Shows in Today on…</label>
|
||||||
|
<input id="task-date" type="date" value={date} onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="min-h-[44px] flex-1 rounded-xl border border-black/10 bg-white px-3 py-2 text-[16px] outline-none focus:border-teal-600 dark:border-white/10 dark:bg-black/30 dark:[color-scheme:dark]" />
|
||||||
|
<button onClick={() => setDate('')} aria-pressed={date === ''} title="Show always (no date)"
|
||||||
|
className={`min-h-[44px] shrink-0 rounded-xl px-3 text-[12px] font-semibold ${date === '' ? 'bg-teal-700 text-white' : 'bg-white text-slate-500 dark:bg-black/30 dark:text-slate-300'}`}>Anytime</button>
|
||||||
|
</div>
|
||||||
{err && <p role="alert" className="mt-2 text-[12px] text-red-600 dark:text-red-300">{err}</p>}
|
{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>}
|
{notice && <p aria-live="polite" className="mt-2 text-[12px] text-amber-700 dark:text-amber-300">{notice}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTask && (
|
{activeTask && showDetail && (
|
||||||
<div className="overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm dark:border-white/10 dark:bg-white/5">
|
<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">
|
<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>
|
<p className="text-[13px] font-semibold">{activeTask.title} <span className="font-normal text-slate-400">· overall duration: {activeTask.totalMin} min</span></p>
|
||||||
@@ -341,7 +386,35 @@ function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: {
|
|||||||
<div className="flex gap-2 p-3">
|
<div className="flex gap-2 p-3">
|
||||||
<button onClick={onFocus} disabled={activeSteps.length === 0}
|
<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>
|
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); } }}
|
<button onClick={async () => {
|
||||||
|
if (!activeTask) return;
|
||||||
|
if (activeSteps.length === 0) {
|
||||||
|
notify('Nothing to export yet — break the task down first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isNativePlatform()) {
|
||||||
|
const ics = buildICS(activeTask, activeSteps);
|
||||||
|
if (!ics) {
|
||||||
|
notify('Nothing to export — all steps are skipped.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { Share } = await import('@capacitor/share');
|
||||||
|
await Share.share({
|
||||||
|
title: `Mindlist: ${activeTask.title}`,
|
||||||
|
text: ics,
|
||||||
|
dialogTitle: 'Export calendar (.ics)',
|
||||||
|
});
|
||||||
|
notify('📅 Calendar shared — save it to your calendar app.');
|
||||||
|
} else if (downloadICS(activeTask, activeSteps)) {
|
||||||
|
notify('📅 Calendar file downloaded — import it into your calendar app.');
|
||||||
|
} else {
|
||||||
|
notify('Nothing to export — all steps are skipped.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify('⚠️ Calendar export failed on this device.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
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">
|
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>
|
<CalendarPlus size={17} /></button>
|
||||||
<button onClick={async () => {
|
<button onClick={async () => {
|
||||||
@@ -352,6 +425,11 @@ function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(!activeTask || !showDetail) && (
|
||||||
|
<p className="rounded-2xl border border-dashed border-teal-300 p-5 text-center text-[13px] text-slate-500 dark:text-slate-400">
|
||||||
|
✨ Type a scary task above and hit <b>Break down</b> —<br />your step-by-step plan will appear here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -740,7 +818,7 @@ function DumpView({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
const plan = heuristicPlan(it.text, 'medium');
|
const plan = heuristicPlan(it.text, 'medium');
|
||||||
const id = uid();
|
const id = uid();
|
||||||
if (!first) first = id;
|
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() });
|
await db.tasks.add({ id, title: it.text, category: 'Personal', difficulty: 'medium', lifeContext: getLifeContext(), totalMin: plan.totalMin, status: 'planned', createdAt: Date.now(), scheduledAt: dayStart() });
|
||||||
for (const [i, st] of plan.steps.entries())
|
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' });
|
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' });
|
||||||
}
|
}
|
||||||
@@ -772,14 +850,15 @@ function DumpView({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
|
|
||||||
/* ---------------- Library ---------------- */
|
/* ---------------- Library ---------------- */
|
||||||
function LibraryView({ tasks, onOpen, onReuse }: { tasks: Task[]; onOpen: (t: Task) => void; onReuse: (t: Task) => void }) {
|
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>;
|
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. Every breakdown you create lands here for one-tap reuse — unlike Today, which only shows what's scheduled. 📚</p>;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<p className="px-1 text-[12px] text-slate-500 dark:text-slate-400">📚 Every breakdown, past and future — tap to open, ⧉ to reuse as a fresh copy scheduled for today.</p>
|
||||||
{tasks.map((t) => (
|
{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">
|
<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">
|
<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="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>
|
<span className="text-[12px] text-slate-500 dark:text-slate-400">{dateChip(t)} · {t.difficulty} · {t.totalMin} min · {t.status}</span>
|
||||||
</button>
|
</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>
|
<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>
|
||||||
@@ -826,10 +905,18 @@ function AiEditSheet({ task, steps, onClose }: { task: Task; steps: Step[]; onCl
|
|||||||
} else {
|
} else {
|
||||||
plan = offlineExpand();
|
plan = offlineExpand();
|
||||||
}
|
}
|
||||||
|
if (plan.steps.length === 0) throw new Error('The edit came back empty — nothing changed.');
|
||||||
|
// Reconcile BEFORE deleting: a failed insert must never strand a stepless task.
|
||||||
|
const merged = reconcilePlan(steps, plan.steps);
|
||||||
await db.steps.where('taskId').equals(task.id).delete();
|
await db.steps.where('taskId').equals(task.id).delete();
|
||||||
for (const [i, s] of plan.steps.entries())
|
for (const [i, s] of merged.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.steps.add({ id: uid(), taskId: task.id, order: i, emoji: s.emoji, text: s.text, minutes: s.minutes, secondsLeft: s.secondsLeft, status: s.status, endsAt: undefined });
|
||||||
await db.tasks.update(task.id, { totalMin: plan.totalMin, status: 'planned' });
|
// An edit that yields fresh work reopens a finished task; pure no-op edits keep it done.
|
||||||
|
const hasFresh = merged.some((s) => s.status === 'todo' || s.status === 'active');
|
||||||
|
await db.tasks.update(task.id, {
|
||||||
|
totalMin: plan.totalMin,
|
||||||
|
status: task.status === 'done' && hasFresh ? 'active' : task.status,
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : 'Edit failed');
|
setErr(e instanceof Error ? e.message : 'Edit failed');
|
||||||
|
|||||||
+159
@@ -136,3 +136,162 @@ describe('buildAlarms', () => {
|
|||||||
expect(alarms.map((a) => a.kind)).toEqual(['end']);
|
expect(alarms.map((a) => a.kind)).toEqual(['end']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { reconcilePlan, type OldStep } from './lib/reconcile';
|
||||||
|
|
||||||
|
const old = (over: Partial<OldStep> & { id: string; text: string }): OldStep => ({
|
||||||
|
emoji: '✅',
|
||||||
|
minutes: 2,
|
||||||
|
secondsLeft: 120,
|
||||||
|
status: 'todo',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconcilePlan', () => {
|
||||||
|
it('preserves done + skipped, resets only changed steps', () => {
|
||||||
|
const r = reconcilePlan(
|
||||||
|
[
|
||||||
|
old({ id: 'a', text: 'Wash cups', status: 'done', secondsLeft: 0 }),
|
||||||
|
old({ id: 'b', text: 'Skip me', status: 'skipped', secondsLeft: 0 }),
|
||||||
|
old({ id: 'c', text: 'Wipe counters' }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ emoji: '☕', text: 'Wash cups', minutes: 2 },
|
||||||
|
{ emoji: '⏭️', text: 'Skip me', minutes: 2 },
|
||||||
|
{ emoji: '✨', text: 'Brand new step', minutes: 3 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(r[0]).toMatchObject({ status: 'done', secondsLeft: 0 });
|
||||||
|
expect(r[1]).toMatchObject({ status: 'skipped', secondsLeft: 0 });
|
||||||
|
expect(r[2]).toMatchObject({ status: 'todo', secondsLeft: 180 });
|
||||||
|
});
|
||||||
|
it('keeps partial remaining time, refills untouched steps on resize', () => {
|
||||||
|
const r = reconcilePlan(
|
||||||
|
[
|
||||||
|
old({ id: 'a', text: 'Half done', status: 'active', secondsLeft: 45, minutes: 2 }),
|
||||||
|
old({ id: 'b', text: 'Untouched', secondsLeft: 120, minutes: 2 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ emoji: '🔥', text: 'Half done', minutes: 5 },
|
||||||
|
{ emoji: '🧹', text: 'Untouched', minutes: 4 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(r[0]).toMatchObject({ status: 'active', secondsLeft: 45 });
|
||||||
|
expect(r[1]).toMatchObject({ status: 'todo', secondsLeft: 240 });
|
||||||
|
});
|
||||||
|
it('offline-expand children inherit the done parent', () => {
|
||||||
|
const r = reconcilePlan(
|
||||||
|
[old({ id: 'a', text: 'Long chore', status: 'done', secondsLeft: 0, minutes: 10 })],
|
||||||
|
[
|
||||||
|
{ emoji: '🧽', text: 'Long chore — part 1', minutes: 5 },
|
||||||
|
{ emoji: '🔍', text: 'Long chore — part 2', minutes: 5 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(r[0].status).toBe('done');
|
||||||
|
expect(r[1].status).toBe('done');
|
||||||
|
});
|
||||||
|
it('duplicate texts consume in order', () => {
|
||||||
|
const r = reconcilePlan(
|
||||||
|
[
|
||||||
|
old({ id: 'a', text: 'Rinse', status: 'done', secondsLeft: 0 }),
|
||||||
|
old({ id: 'b', text: 'Rinse', status: 'todo', secondsLeft: 60, minutes: 1 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ emoji: '💧', text: 'Rinse', minutes: 2 },
|
||||||
|
{ emoji: '💧', text: 'Rinse', minutes: 2 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(r[0].status).toBe('done');
|
||||||
|
expect(r[1]).toMatchObject({ status: 'todo', secondsLeft: 120 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { dateChip, dayStart, fromISODate, isDue, toISODate, type Task } from './lib/store';
|
||||||
|
|
||||||
|
const t = (over: Partial<Task> & { id: string }): Task => ({
|
||||||
|
title: 'x',
|
||||||
|
category: 'Home',
|
||||||
|
difficulty: 'medium',
|
||||||
|
lifeContext: '',
|
||||||
|
totalMin: 10,
|
||||||
|
status: 'planned',
|
||||||
|
createdAt: 0,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scheduling', () => {
|
||||||
|
it('dayStart/toISODate/fromISODate round-trip', () => {
|
||||||
|
const iso = toISODate(new Date(2026, 9, 2, 15, 30));
|
||||||
|
expect(iso).toBe('2026-10-02');
|
||||||
|
expect(fromISODate(iso)).toBe(dayStart(new Date(2026, 9, 2, 12)));
|
||||||
|
});
|
||||||
|
it('isDue: unscheduled + today + overdue shown, future + done hidden', () => {
|
||||||
|
const today = dayStart();
|
||||||
|
expect(isDue(t({ id: 'a' }))).toBe(true);
|
||||||
|
expect(isDue(t({ id: 'b', scheduledAt: today }))).toBe(true);
|
||||||
|
expect(isDue(t({ id: 'c', scheduledAt: today - 86400000 }))).toBe(true);
|
||||||
|
expect(isDue(t({ id: 'd', scheduledAt: today + 86400000 }))).toBe(false);
|
||||||
|
expect(isDue(t({ id: 'e', scheduledAt: today, status: 'done' }))).toBe(false);
|
||||||
|
});
|
||||||
|
it('dateChip labels', () => {
|
||||||
|
const today = dayStart();
|
||||||
|
expect(dateChip(t({ id: 'a' }))).toBe('Anytime');
|
||||||
|
expect(dateChip(t({ id: 'b', scheduledAt: today }))).toBe('Today');
|
||||||
|
expect(dateChip(t({ id: 'c', scheduledAt: today - 86400000 }))).toBe('Overdue');
|
||||||
|
expect(dateChip(t({ id: 'd', scheduledAt: today + 86400000 }))).toBe('Tomorrow');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { buildICS } from './lib/store';
|
||||||
|
|
||||||
|
describe('reconcilePlan documented limits', () => {
|
||||||
|
it('a reworded step is treated as new work (state resets)', () => {
|
||||||
|
const r = reconcilePlan(
|
||||||
|
[old({ id: 'a', text: 'Wash cups', status: 'done', secondsLeft: 0 })],
|
||||||
|
[{ emoji: '☕', text: 'Wash mugs', minutes: 2 }],
|
||||||
|
);
|
||||||
|
expect(r[0]).toMatchObject({ status: 'todo', secondsLeft: 120 });
|
||||||
|
});
|
||||||
|
it('emoji-only diffs preserve state (emoji is not matched on)', () => {
|
||||||
|
const r = reconcilePlan(
|
||||||
|
[old({ id: 'a', text: 'Wipe counters', status: 'done', secondsLeft: 0 })],
|
||||||
|
[{ emoji: '🧽', text: 'Wipe counters', minutes: 2 }],
|
||||||
|
);
|
||||||
|
expect(r[0].status).toBe('done');
|
||||||
|
});
|
||||||
|
it('one done step triplicated stays done on all copies', () => {
|
||||||
|
const r = reconcilePlan(
|
||||||
|
[old({ id: 'a', text: 'Rinse', status: 'done', secondsLeft: 0 })],
|
||||||
|
[
|
||||||
|
{ emoji: '💧', text: 'Rinse', minutes: 1 },
|
||||||
|
{ emoji: '💧', text: 'Rinse', minutes: 1 },
|
||||||
|
{ emoji: '💧', text: 'Rinse', minutes: 1 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(r.map((s) => s.status)).toEqual(['done', 'done', 'done']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('export + date guards', () => {
|
||||||
|
it("fromISODate rejects '' and garbage", () => {
|
||||||
|
expect(fromISODate('')).toBeNull();
|
||||||
|
expect(fromISODate('not-a-date')).toBeNull();
|
||||||
|
expect(fromISODate('2026-10-02')).toBe(dayStart(new Date(2026, 9, 2, 12)));
|
||||||
|
});
|
||||||
|
it('buildICS returns null when everything is skipped', () => {
|
||||||
|
const task = t({ id: 'x', title: 'T' });
|
||||||
|
const steps = [
|
||||||
|
{ id: 's1', taskId: 'x', order: 0, emoji: '✅', text: 'a', minutes: 1, secondsLeft: 0, status: 'skipped' as const },
|
||||||
|
];
|
||||||
|
expect(buildICS(task, steps)).toBeNull();
|
||||||
|
});
|
||||||
|
it('buildICS emits events otherwise', () => {
|
||||||
|
const task = t({ id: 'x', title: 'T' });
|
||||||
|
const steps = [
|
||||||
|
{ id: 's1', taskId: 'x', order: 0, emoji: '✅', text: 'a', minutes: 1, secondsLeft: 60, status: 'todo' as const },
|
||||||
|
];
|
||||||
|
const ics = buildICS(task, steps);
|
||||||
|
expect(ics).toContain('BEGIN:VEVENT');
|
||||||
|
expect(ics).toContain('BEGIN:VCALENDAR');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// AI-Edit state preservation (#6, pure + unit-tested).
|
||||||
|
// When a plan is edited mid-execution, new steps that match old ones keep
|
||||||
|
// their done/skipped state and remaining time instead of resetting.
|
||||||
|
|
||||||
|
export interface OldStep {
|
||||||
|
id: string;
|
||||||
|
emoji: string;
|
||||||
|
text: string;
|
||||||
|
minutes: number;
|
||||||
|
secondsLeft: number;
|
||||||
|
status: 'todo' | 'active' | 'done' | 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewStepIn {
|
||||||
|
emoji: string;
|
||||||
|
text: string;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconciledStep {
|
||||||
|
emoji: string;
|
||||||
|
text: string;
|
||||||
|
minutes: number;
|
||||||
|
secondsLeft: number;
|
||||||
|
status: 'todo' | 'active' | 'done' | 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm(t: string): string {
|
||||||
|
return t
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
// offline-expand children match their parent ("— part 1", "— part 2 (gather)", …)
|
||||||
|
.replace(/\s+—\s*part\b.*$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcilePlan(oldSteps: OldStep[], newSteps: NewStepIn[]): ReconciledStep[] {
|
||||||
|
const queue = new Map<string, OldStep[]>();
|
||||||
|
const terminal = new Map<string, OldStep>();
|
||||||
|
for (const o of oldSteps) {
|
||||||
|
const k = norm(o.text);
|
||||||
|
const arr = queue.get(k);
|
||||||
|
if (arr) arr.push(o);
|
||||||
|
else queue.set(k, [o]);
|
||||||
|
// Terminal states are shareable and never deplete: a done step that
|
||||||
|
// gets split (or duplicated) stays done on every child.
|
||||||
|
if ((o.status === 'done' || o.status === 'skipped') && !terminal.has(k)) terminal.set(k, o);
|
||||||
|
}
|
||||||
|
return newSteps.map((n) => {
|
||||||
|
const full = Math.max(1, Math.round(n.minutes)) * 60;
|
||||||
|
// Consume unfinished matches in order (each is distinct work); fall
|
||||||
|
// back to a shareable terminal match when the queue is empty.
|
||||||
|
const old = queue.get(norm(n.text))?.shift() ?? terminal.get(norm(n.text));
|
||||||
|
if (!old) {
|
||||||
|
return { emoji: n.emoji, text: n.text, minutes: Math.max(1, Math.round(n.minutes)), secondsLeft: full, status: 'todo' as const };
|
||||||
|
}
|
||||||
|
if (old.status === 'done' || old.status === 'skipped') {
|
||||||
|
return { emoji: n.emoji, text: n.text, minutes: Math.max(1, Math.round(n.minutes)), secondsLeft: 0, status: old.status };
|
||||||
|
}
|
||||||
|
const untouched = old.secondsLeft >= old.minutes * 60 - 1;
|
||||||
|
return {
|
||||||
|
emoji: n.emoji,
|
||||||
|
text: n.text,
|
||||||
|
minutes: Math.max(1, Math.round(n.minutes)),
|
||||||
|
secondsLeft: untouched ? full : Math.min(old.secondsLeft, full),
|
||||||
|
status: old.status,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
+53
-7
@@ -11,6 +11,8 @@ export interface Task {
|
|||||||
status: 'planned' | 'active' | 'done';
|
status: 'planned' | 'active' | 'done';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
completedAt?: number;
|
completedAt?: number;
|
||||||
|
/** Day-start epoch ms this task is scheduled for. Absent = unscheduled (shown today). */
|
||||||
|
scheduledAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Step {
|
export interface Step {
|
||||||
@@ -40,6 +42,40 @@ export const db = new DB();
|
|||||||
export const uid = () =>
|
export const uid = () =>
|
||||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
export function dayStart(d = new Date()): number {
|
||||||
|
const c = new Date(d);
|
||||||
|
c.setHours(0, 0, 0, 0);
|
||||||
|
return c.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toISODate(d = new Date()): string {
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromISODate(iso: string): number | null {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
|
||||||
|
const [y, m, d] = iso.split('-').map(Number);
|
||||||
|
const t = new Date(y, m - 1, d).getTime();
|
||||||
|
return Number.isNaN(t) ? null : dayStart(new Date(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduled today/overdue (or unscheduled) and not done → belongs on Today. */
|
||||||
|
export function isDue(task: Task, now = Date.now()): boolean {
|
||||||
|
if (task.status === 'done') return false;
|
||||||
|
if (task.scheduledAt == null) return true;
|
||||||
|
return task.scheduledAt <= dayStart(new Date(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateChip(task: Task, now = Date.now()): string {
|
||||||
|
if (task.scheduledAt == null) return 'Anytime';
|
||||||
|
const today = dayStart(new Date(now));
|
||||||
|
const diffDays = Math.round((task.scheduledAt - today) / 86400000);
|
||||||
|
if (diffDays <= 0) return diffDays < 0 ? 'Overdue' : 'Today';
|
||||||
|
if (diffDays === 1) return 'Tomorrow';
|
||||||
|
return new Date(task.scheduledAt).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
export function getLifeContext(): string {
|
export function getLifeContext(): string {
|
||||||
return localStorage.getItem('mindlist.context') ?? '';
|
return localStorage.getItem('mindlist.context') ?? '';
|
||||||
}
|
}
|
||||||
@@ -67,11 +103,27 @@ function icsEscape(s: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function downloadICS(task: Task, steps: Step[]) {
|
export function downloadICS(task: Task, steps: Step[]) {
|
||||||
|
const ics = buildICS(task, steps);
|
||||||
|
if (!ics) return false;
|
||||||
|
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`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built ICS text, or null when there is nothing to export. */
|
||||||
|
export function buildICS(task: Task, steps: Step[]): string | null {
|
||||||
const dt = new Date();
|
const dt = new Date();
|
||||||
const fmt = (d: Date) =>
|
const fmt = (d: Date) =>
|
||||||
d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||||
let cursor = new Date(dt.getTime() + 5 * 60000);
|
let cursor = new Date(dt.getTime() + 5 * 60000);
|
||||||
const live = steps.filter((s) => s.status !== 'skipped');
|
const live = steps.filter((s) => s.status !== 'skipped');
|
||||||
|
if (live.length === 0) return null;
|
||||||
const events = live
|
const events = live
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const mins = Math.max(1, Math.ceil(s.secondsLeft / 60) || s.minutes);
|
const mins = Math.max(1, Math.ceil(s.secondsLeft / 60) || s.minutes);
|
||||||
@@ -82,11 +134,5 @@ export function downloadICS(task: Task, steps: Step[]) {
|
|||||||
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`;
|
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');
|
.join('\n');
|
||||||
const ics = `BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Mindlist//FOSS//EN\n${events}\nEND:VCALENDAR`;
|
return `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);
|
|
||||||
}
|
}
|
||||||
+1
-1
@@ -18,7 +18,7 @@ export default defineConfig({
|
|||||||
theme_color: '#0f766e',
|
theme_color: '#0f766e',
|
||||||
background_color: '#15181e',
|
background_color: '#15181e',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
orientation: 'portrait',
|
orientation: 'any',
|
||||||
start_url: '.',
|
start_url: '.',
|
||||||
icons: [
|
icons: [
|
||||||
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
|||||||
Reference in new issue
Block a user