Mindlist v1: FOSS Neurolist clone (PWA + Capacitor Android), v3 logo, signed release

This commit is contained in:
Lukas Holzner committed 2026-10-02 10:18:13 +02:00
commit 4fc6be04da
90 files changed
+10761

No files matched your search

+97
View File
@@ -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 */ }
}
+96
View File
@@ -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);
}
+40
View File
@@ -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)'}.`;
}
+89
View File
@@ -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);
}