diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 2a726af..8f9a8b8 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -12,6 +12,7 @@ dependencies { implementation project(':capacitor-community-text-to-speech') implementation project(':capacitor-app') implementation project(':capacitor-local-notifications') + implementation project(':capacitor-share') } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index d8f75e6..08444a5 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -10,3 +10,6 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/ include ':capacitor-local-notifications' 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') diff --git a/package-lock.json b/package-lock.json index ec12a0b..3466f35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@capacitor/cli": "^8.5.2", "@capacitor/core": "^8.5.2", "@capacitor/local-notifications": "^8.3.1", + "@capacitor/share": "^8.0.2", "@tailwindcss/vite": "^4.3.3", "dexie": "^4.4.6", "dexie-react-hooks": "^4.4.0", @@ -1615,6 +1616,15 @@ "@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": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", diff --git a/package.json b/package.json index 51cb360..79775d1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@capacitor/cli": "^8.5.2", "@capacitor/core": "^8.5.2", "@capacitor/local-notifications": "^8.3.1", + "@capacitor/share": "^8.0.2", "@tailwindcss/vite": "^4.3.3", "dexie": "^4.4.6", "dexie-react-hooks": "^4.4.0", diff --git a/src/App.tsx b/src/App.tsx index 20fca34..4964559 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,6 @@ import { Play, Plus, Settings2, - Sparkles, Sun, Trash2, Volume2, @@ -25,6 +24,7 @@ import { App as CapApp } from '@capacitor/app'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { beep, speak, stopSpeak } from './lib/audio'; import { buildAlarms, catchUp, WARN_BEFORE_MS, type Alarm } from './lib/catchup'; +import { reconcilePlan } from './lib/reconcile'; import { heuristicPlan } from './lib/fallback'; import { fgStart, fgStop, fgUpdate } from './lib/fgservice'; import { @@ -46,13 +46,19 @@ import { import { buildAiEditSystem, buildBrainDumpSystem, buildSystemPrompt, type Difficulty } from './lib/prompts'; import { db, + buildICS, + dateChip, + dayStart, downloadICS, + fromISODate, getDark, getLifeContext, getVoiceOn, + isDue, setDark, setLifeContext, setVoiceOn, + toISODate, uid, type Step, type Task, @@ -81,6 +87,13 @@ export default function App() { const [showAiEdit, setShowAiEdit] = useState(false); const [dark, setDarkState] = useState(getDark()); const [voice, setVoice] = useState(getVoiceOn()); + const [toast, setToast] = useState(null); + const toastTimer = useRef(null); + const notify = useCallback((msg: string) => { + setToast(msg); + if (toastTimer.current) window.clearTimeout(toastTimer.current); + toastTimer.current = window.setTimeout(() => setToast(null), 4000); + }, []); useEffect(() => { document.documentElement.classList.toggle('dark', dark); @@ -94,7 +107,9 @@ export default function App() { [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( () => (catFilter === 'All' ? tasks : tasks.filter((t) => t.category === catFilter)), [tasks, catFilter], @@ -102,7 +117,7 @@ export default function App() { return (
-
+
{/* header */}
@@ -134,15 +149,16 @@ export default function App() {
{view === 'today' && ( - { setActiveTaskId(t.id); setView('new'); }} onFocus={(t) => { setActiveTaskId(t.id); setFocusMode(true); }} /> )} {view === 'new' && ( - setActiveTaskId(id)} onFocus={() => setFocusMode(true)} - onEdit={() => setShowAiEdit(true)} /> + onEdit={() => setShowAiEdit(true)} + notify={notify} /> )} {view === 'dump' && { setActiveTaskId(id); setView('new'); }} />} {view === 'library' && ( @@ -151,23 +167,25 @@ export default function App() { 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 }); + await db.tasks.add({ ...t, id: nid, status: 'planned', createdAt: Date.now(), completedAt: undefined, scheduledAt: dayStart() }); 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'); }} /> )}
{/* bottom nav */} -
); @@ -192,43 +216,55 @@ function NavBtn({ icon, label, active, onClick }: { icon: React.ReactNode; label } /* ---------------- Today ---------------- */ -function TodayView({ tasks, doneCount, activeSteps, onOpen, onFocus }: { - tasks: Task[]; doneCount: number; activeSteps: Step[]; +function TodayView({ tasks, doneCount, onOpen, onFocus }: { + tasks: Task[]; doneCount: number; 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'); + 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 (
-

πŸ“… {today} Β· πŸŽ‰ {doneCount} tasks overall completed

+

πŸ“… {today} Β· πŸŽ‰ {doneCount} done today

{active ? (

β–Ά {active.title}

-

{activeSteps.filter((s) => s.status === 'done').length}/{activeSteps.length} steps Β· ~{active.totalMin} min

+

{heroDone}/{heroSteps.length} steps Β· ~{active.totalMin} min

- +
) : ( -

Nothing planned. Tap + New or Dump to break a scary task down. 🌱

+

Nothing scheduled today. Tap + or Dump to break a scary task down. 🌱

)}
- {tasks.map((t) => ( - - ))} - {tasks.length === 0 && ( + {due.map((t) => { + const chip = dateChip(t); + const overdue = chip === 'Overdue'; + return ( + + ); + })} + {due.length === 0 && (
- 🧠 #1 AI Planner for ADHD & neurodivergence
Break down stressful tasks β†’ focus timer keeps you on track. + 🧠 All clear for today.
Future tasks live under their date β€” everything reusable lives in πŸ“š Library.
)}
@@ -236,13 +272,14 @@ function TodayView({ tasks, doneCount, activeSteps, onOpen, onFocus }: { } /* ---------------- Composer ---------------- */ -function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: { - activeTask: Task | null; activeSteps: Step[]; - onCreated: (id: string) => void; onFocus: () => void; onEdit: () => void; +function ComposerView({ activeTask, activeSteps, showDetail, onCreated, onFocus, onEdit, notify }: { + activeTask: Task | null; activeSteps: Step[]; showDetail: boolean; + onCreated: (id: string) => void; onFocus: () => void; onEdit: () => void; notify: (msg: string) => void; }) { const [title, setTitle] = useState(''); const [category, setCategory] = useState('Home'); const [difficulty, setDifficulty] = useState('medium'); + const [date, setDate] = useState(() => toISODate()); const [loading, setLoading] = useState(false); const [err, setErr] = useState(''); const [notice, setNotice] = useState(''); @@ -267,12 +304,13 @@ function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: { } else { plan = heuristicPlan(t, difficulty); // offline demo mode } + const scheduledAt = date ? (fromISODate(date) ?? undefined) : undefined; 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 }); + await db.tasks.update(id, { difficulty, category, totalMin: plan.totalMin, status: 'planned', lifeContext: ctx, scheduledAt }); } 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()) 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 { setLoading(false); } - }, [title, activeTask, category, difficulty, onCreated]); + }, [title, activeTask, category, difficulty, date, 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 }); @@ -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} ))} +
+ + 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]" /> + +
{err &&

{err}

} {notice &&

{notice}

}
- {activeTask && ( + {activeTask && showDetail && (

{activeTask.title} Β· overall duration: {activeTask.totalMin} min

@@ -341,7 +386,35 @@ function ComposerView({ activeTask, activeSteps, onCreated, onFocus, onEdit }: {
-
)} + {(!activeTask || !showDetail) && ( +

+ ✨ Type a scary task above and hit Break down β€”
your step-by-step plan will appear here. +

+ )}
); } @@ -740,7 +818,7 @@ function DumpView({ onCreated }: { onCreated: (id: string) => void }) { 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() }); + 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()) 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 ---------------- */ function LibraryView({ tasks, onOpen, onReuse }: { tasks: Task[]; onOpen: (t: Task) => void; onReuse: (t: Task) => void }) { - if (!tasks.length) return

Library empty. Breakdowns you create are saved here for one-tap reuse. πŸ“š

; + if (!tasks.length) return

Library empty. Every breakdown you create lands here for one-tap reuse β€” unlike Today, which only shows what's scheduled. πŸ“š

; return (
+

πŸ“š Every breakdown, past and future β€” tap to open, ⧉ to reuse as a fresh copy scheduled for today.

{tasks.map((t) => (
@@ -826,10 +905,18 @@ function AiEditSheet({ task, steps, onClose }: { task: Task; steps: Step[]; onCl } else { 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(); - 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' }); + 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.secondsLeft, status: s.status, endsAt: undefined }); + // 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(); } catch (e) { setErr(e instanceof Error ? e.message : 'Edit failed'); diff --git a/src/app.test.ts b/src/app.test.ts index 95f8745..eeb07fc 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -136,3 +136,162 @@ describe('buildAlarms', () => { expect(alarms.map((a) => a.kind)).toEqual(['end']); }); }); + +import { reconcilePlan, type OldStep } from './lib/reconcile'; + +const old = (over: Partial & { 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 & { 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'); + }); +}); diff --git a/src/lib/reconcile.ts b/src/lib/reconcile.ts new file mode 100644 index 0000000..5d31fb5 --- /dev/null +++ b/src/lib/reconcile.ts @@ -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(); + const terminal = new Map(); + 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, + }; + }); +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 945e421..f05ef5f 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -11,6 +11,8 @@ export interface Task { status: 'planned' | 'active' | 'done'; createdAt: number; completedAt?: number; + /** Day-start epoch ms this task is scheduled for. Absent = unscheduled (shown today). */ + scheduledAt?: number; } export interface Step { @@ -40,6 +42,40 @@ export const db = new DB(); export const uid = () => `${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 { return localStorage.getItem('mindlist.context') ?? ''; } @@ -67,11 +103,27 @@ function icsEscape(s: string): string { } 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 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'); + if (live.length === 0) return null; const events = live .map((s) => { 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`; }) .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); + return `BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Mindlist//FOSS//EN\n${events}\nEND:VCALENDAR`; } diff --git a/vite.config.ts b/vite.config.ts index b6bca86..03dbc17 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ theme_color: '#0f766e', background_color: '#15181e', display: 'standalone', - orientation: 'portrait', + orientation: 'any', start_url: '.', icons: [ { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },