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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user