// Structured surveys driven from scheduled missions and consumed by the // bot text handler when a survey is active for a given chat_id. import { appendToDailySection, parseFrontmatter, readDailyNote, serializeFrontmatter, todayIso, vaultPath, VAULT_SUBDIRS, writeDailyNote } from './vault.js' import { simpleGit } from 'simple-git' import { VAULT_PATH } from './config.js' import { logger } from './logger.js' import { clearSurvey, currentSurvey, startSurvey, advanceSurvey } from './conversation-state.js' import { vaultRelFrom } from './vault.js' import { escapeHtml } from './format-telegram.js' import { listParkedIdeas } from './idea-open.js' import { upsertTask } from './tasks.js' import { createCalendarBlock } from './calendar.js' const git = simpleGit(VAULT_PATH) const MORNING_QUESTIONS = [ { key: 'focus', prompt: 'โ˜€๏ธ Morning ritual โ€” send 1โ€“3 lines of focus for today. I\'ll write them into ## Focus.', }, { key: 'thesis_artifact', prompt: 'What\'s today\'s one tangible thesis artifact? (skip with skip)', }, { key: 'venture_artifact', prompt: 'Today\'s one tangible venture artifact? (skip with skip)', }, { key: 'needle_tasks', prompt: 'What 3 things move the needle today? Send one per line. Add block time on a line if I should create a calendar block.', }, ] as const const EVENING_QUESTIONS = [ { key: 'sleep_hours', prompt: '๐ŸŒ™ Evening log โ€” how many hours of sleep last night? (number)', }, { key: 'energy', prompt: 'Energy today 1โ€“10?' }, { key: 'soreness', prompt: 'Soreness today 1โ€“10? (0 if none)' }, { key: 'swim', prompt: 'Did you swim today? y / n' }, { key: 'sport', prompt: 'Sport today? (name or none)' }, { key: 'kit_done', prompt: 'Kit done? y / n' }, { key: 'meditation_done', prompt: 'Meditation done? y / n' }, { key: 'reflection', prompt: 'One sentence reflection? (skip to skip)' }, ] as const type Send = (html: string) => Promise function normaliseYesNo(raw: string): 'true' | 'false' | null { const v = raw.trim().toLowerCase() if (['y', 'yes', 'done', '1', 'true'].includes(v)) return 'true' if (['n', 'no', 'skip', 'missed', '0', 'false'].includes(v)) return 'false' return null } export async function openMorningSurvey(chatId: string, send: Send): Promise { startSurvey({ kind: 'morning', chatId, step: 0, started: Date.now(), data: {} }) await send(MORNING_QUESTIONS[0]!.prompt) } export async function openEveningSurvey(chatId: string, send: Send): Promise { startSurvey({ kind: 'evening', chatId, step: 0, started: Date.now(), data: {} }) await send(EVENING_QUESTIONS[0]!.prompt) } export async function openWeeklyReviewSurvey(chatId: string, send: Send): Promise { const parked = listParkedIdeas(20) if (parked.length === 0) { await send('Weekly review ยท no parked ideas from this week.') return } startSurvey({ kind: 'weekly-review', chatId, step: 0, started: Date.now(), data: { queue: JSON.stringify(parked.map(p => p.slug)) }, }) const first = parked[0]! await send( `Weekly review โ€” decide each idea.\n\n1/${parked.length}: ${escapeHtml(first.slug)}${first.title ? ` โ€” ${escapeHtml(first.title)}` : ''}\n\nreply open [new name] ยท park ยท discard` ) } async function commitDaily(isoDate: string, op: string): Promise { const abs = vaultPath(VAULT_SUBDIRS.daily, `${isoDate}.md`) const rel = vaultRelFrom(abs) try { await git.add(rel) await git.commit(`[mc] daily: ${rel} ${op}`) try { await git.push() } catch (err) { logger.warn({ err: err instanceof Error ? err.message : err }, 'daily push skipped') } } catch (err) { logger.warn({ err: err instanceof Error ? err.message : err }, 'daily commit failed') } } async function applyEveningToDaily(isoDate: string, data: Record): Promise { const raw = (await readDailyNote(isoDate)) ?? '' const { frontmatter, body } = parseFrontmatter(raw) const fm: Record = { ...frontmatter } if (data.sleep_hours && !Number.isNaN(Number(data.sleep_hours))) { fm.sleep_hours = Number(data.sleep_hours) } if (data.energy) fm.energy = data.energy if (data.soreness) fm.soreness = data.soreness if (data.swim) fm.swim = data.swim === 'true' if (data.sport && data.sport.toLowerCase() !== 'none') fm.sport = data.sport if (data.kit_done) fm.kit_done = data.kit_done === 'true' if (data.meditation_done) fm.meditation_done = data.meditation_done === 'true' const next = serializeFrontmatter(fm as Record) + '\n' + (body.startsWith('\n') ? body.slice(1) : body) await writeDailyNote(isoDate, next) if (data.reflection && data.reflection.toLowerCase() !== 'skip') { await appendToDailySection(isoDate, 'Notes (quick capture)', `- eod: ${data.reflection}`) } } /** Called by the bot text handler when a survey is active for the chat. */ export async function handleSurveyReply( chatId: string, text: string, send: Send ): Promise { const survey = currentSurvey(chatId) if (!survey) return false const raw = text.trim() if (survey.kind === 'morning') { const q = MORNING_QUESTIONS[survey.step] if (!q) { clearSurvey(chatId) return false } if (raw.toLowerCase() === 'cancel') { clearSurvey(chatId) await send('morning ritual cancelled.') return true } advanceSurvey(chatId, { [q.key]: raw }) const today = todayIso() if (q.key === 'focus' && raw.toLowerCase() !== 'skip') { const lines = raw.split(/\n+/).map(l => l.trim()).filter(Boolean).slice(0, 3) for (const line of lines) await appendToDailySection(today, 'Focus (pick 1โ€“3)', `- ${line}`) } else if (q.key === 'thesis_artifact' && raw.toLowerCase() !== 'skip') { await appendToDailySection(today, 'Thesis artifact (one tangible thing)', `- ${raw}`) } else if (q.key === 'venture_artifact' && raw.toLowerCase() !== 'skip') { await appendToDailySection(today, 'Venture artifact (one tangible thing)', `- ${raw}`) } else if (q.key === 'needle_tasks' && raw.toLowerCase() !== 'skip') { await createNeedleTasks(today, raw) } const next = MORNING_QUESTIONS[survey.step] if (next) { await send(next.prompt) } else { clearSurvey(chatId) await commitDaily(today, 'morning ritual') await send('โœ… morning ritual logged.') } return true } if (survey.kind === 'evening') { const q = EVENING_QUESTIONS[survey.step] if (!q) { clearSurvey(chatId) return false } if (raw.toLowerCase() === 'cancel') { clearSurvey(chatId) await send('evening log cancelled.') return true } let value = raw if (['swim', 'kit_done', 'meditation_done'].includes(q.key)) { const yn = normaliseYesNo(raw) if (!yn) { await send('please reply y or n.') return true } value = yn } else if (['energy', 'soreness', 'sleep_hours'].includes(q.key)) { const n = Number(raw) if (!Number.isFinite(n)) { await send('please send a number.') return true } value = String(n) } advanceSurvey(chatId, { [q.key]: value }) const next = EVENING_QUESTIONS[survey.step] if (next) { await send(next.prompt) } else { const today = todayIso() await applyEveningToDaily(today, survey.data) await commitDaily(today, 'evening tracker') clearSurvey(chatId) await send('โœ… evening log saved to daily note.') } return true } if (survey.kind === 'weekly-review') { if (raw.toLowerCase() === 'cancel') { clearSurvey(chatId) await send('weekly review cancelled.') return true } const queue: string[] = (() => { try { return JSON.parse(survey.data.queue ?? '[]') as string[] } catch { return [] } })() const slug = queue[survey.step] if (!slug) { clearSurvey(chatId) await send('โœ… weekly review complete.') return true } const [verb, ...rest] = raw.split(/\s+/) const name = rest.join(' ').trim() || undefined try { if (verb === 'open') { const { openIdea } = await import('./idea-open.js') const r = await openIdea(slug, name) await send(`opened โ†’ ${escapeHtml(r.projectPath)}`) } else if (verb === 'discard') { const { discardIdea } = await import('./idea-open.js') const r = await discardIdea(slug) await send(`archived โ†’ ${escapeHtml(r.archivedPath)}`) } else if (verb === 'park') { await send(`kept parked.`) } else { await send('reply with open [name] / park / discard.') return true } } catch (err) { await send(`โš ๏ธ ${err instanceof Error ? err.message : String(err)}`) } advanceSurvey(chatId, { [`decision_${survey.step}`]: raw }) const nextSlug = queue[survey.step] if (!nextSlug) { clearSurvey(chatId) await send('โœ… weekly review complete.') return true } await send(`${survey.step + 1}/${queue.length}: ${escapeHtml(nextSlug)}\n\nopen [name] ยท park ยท discard`) return true } return false } async function createNeedleTasks(isoDate: string, raw: string): Promise { const lines = raw.split(/\n+/).map(l => l.trim()).filter(Boolean).slice(0, 3) const due = dueAt(isoDate, 18, 0) for (const [idx, line] of lines.entries()) { const wantsBlock = /\bblock\s*time\b|\[block\]|\bblock:/i.test(line) const title = line .replace(/^\s*[-*]\s*(?:\[[ x]\]\s*)?/i, '') .replace(/\bblock\s*time\b|\[block\]|\bblock:/ig, '') .replace(/\s+/g, ' ') .trim() if (!title) continue await appendToDailySection(isoDate, 'Focus (pick 1โ€“3)', `- [ ] ${title}`) await upsertTask({ title, notes: `Howl PA morning needle task. Daily note: 03_Daily/${isoDate}.md`, due, importance: 80, importanceReason: 'morning needle task', }) if (wantsBlock) { const start = dueAt(isoDate, 15 + idx, 0) const end = new Date(start.getTime() + 45 * 60_000) await createCalendarBlock({ summary: `Needle: ${title}`, startsAt: start, endsAt: end, description: `Created from Howl PA morning ritual. Due by ${due.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: false })}.`, }) } } } function dueAt(isoDate: string, hour: number, minute: number): Date { const d = new Date(`${isoDate}T00:00:00`) d.setHours(hour, minute, 0, 0) return d }