Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.13.7
1.16.0
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "mentell",
"private": true,
"version": "1.13.7",
"version": "1.16.0",
"type": "module",
"scripts": {
"sync:assets": "node scripts/sync-assets.mjs",
Expand Down
37 changes: 26 additions & 11 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ export type EntryRow = {
situation: string
details: string
flaggedTerms: string[]
warningLevel: WarningLevel
riskScore: number
riskLevel: RiskLevel
warningLevel: WarningLevel
riskScore: number
interventionScore: number
riskLevel: RiskLevel
scoreDelta: number
streakAtSubmit: number
}
Expand Down Expand Up @@ -144,9 +145,9 @@ export class MentellDB extends Dexie {
characterAppearance: '&id, updatedAt',
})

this.version(6)
.stores({
entries: '&id, dateKey, createdAt, updatedAt, sentiment, warningLevel, riskLevel',
this.version(6)
.stores({
entries: '&id, dateKey, createdAt, updatedAt, sentiment, warningLevel, riskLevel',
notes: '&id, createdAt, updatedAt, tag',
stickies: '&id, createdAt, updatedAt, zIndex',
packages: '&id, kind, periodKey, createdAt, updatedAt, openedAt',
Expand All @@ -155,11 +156,25 @@ export class MentellDB extends Dexie {
.upgrade(async (tx) => {
await tx.table('entries').toCollection().modify((row: Partial<EntryRow>) => {
if (typeof row.riskScore !== 'number') row.riskScore = row.warningLevel === 'warn' ? 0.5 : 0
if (!row.riskLevel) row.riskLevel = row.warningLevel === 'warn' ? 'elevated' : 'none'
})
})
}
}
if (!row.riskLevel) row.riskLevel = row.warningLevel === 'warn' ? 'elevated' : 'none'
})
})

this.version(7)
.stores({
entries: '&id, dateKey, createdAt, updatedAt, sentiment, warningLevel, riskLevel',
notes: '&id, createdAt, updatedAt, tag',
stickies: '&id, createdAt, updatedAt, zIndex',
packages: '&id, kind, periodKey, createdAt, updatedAt, openedAt',
characterAppearance: '&id, updatedAt',
})
.upgrade(async (tx) => {
await tx.table('entries').toCollection().modify((row: Partial<EntryRow>) => {
if (typeof row.interventionScore !== 'number') row.interventionScore = 0
})
})
}
}

let dbInstance: MentellDB | null = null
let dbInstanceName: string | null = null
Expand Down
7 changes: 4 additions & 3 deletions src/db/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ export const EntryRowSchema = z.object({
situation: z.string(),
details: z.string(),
flaggedTerms: z.array(z.string()),
warningLevel: WarningLevelSchema,
riskScore: z.number().min(0).max(1),
riskLevel: RiskLevelSchema,
warningLevel: WarningLevelSchema,
riskScore: z.number().min(0).max(1),
interventionScore: z.number(),
riskLevel: RiskLevelSchema,
scoreDelta: z.number().int(),
streakAtSubmit: z.number().int().nonnegative(),
})
Expand Down
179 changes: 115 additions & 64 deletions src/features/compose/LetterComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import React, { useEffect, useMemo, useState } from 'react'
import { ProgressLight, type ProgressState } from '../../components/ProgressLight'
import { SentimentPills, type SentimentValue } from '../../components/SentimentPills'
import { dateKeyForLocalDay } from '../../shared/dates'
import { assessLocalRisk, assessRisk, type RiskAssessment } from '../safety/riskAssessment'
import { CrisisResourcePanel } from '../safety/CrisisResourcePanel'
import type { EntryEmotion, RiskLevel } from '../../db/schema'
import React, { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ProgressLight, type ProgressState } from '../../components/ProgressLight'
import { SentimentPills, type SentimentValue } from '../../components/SentimentPills'
import { dateKeyForLocalDay } from '../../shared/dates'
import { isDebugMode } from '../../shared/debug/debugFlags'
import { motionDuration, shouldReduceMotion } from '../../shared/motion/useMotionPrefs'
import { assessLocalRisk, assessRisk, type RiskAssessment } from '../safety/riskAssessment'
import { CrisisResourcePanel } from '../safety/CrisisResourcePanel'
import type { EntryEmotion, RiskLevel } from '../../db/schema'

type Draft = {
dateKey: string
Expand All @@ -14,19 +17,28 @@ type Draft = {
situation: string
details: string
flaggedTerms: string[]
warningLevel: 'none' | 'warn'
riskScore: number
riskLevel: RiskLevel
warningLevel: 'none' | 'warn'
riskScore: number
interventionScore: number
riskLevel: RiskLevel
}

const EMOTION_OPTIONS: Array<{ value: EntryEmotion; label: string }> = [
const EMOTION_OPTIONS: Array<{ value: EntryEmotion; label: string }> = [
{ value: 'happy', label: '🙂 Happy' },
{ value: 'calm', label: '😌 Calm' },
{ value: 'anxious', label: '😟 Anxious' },
{ value: 'sad', label: '😔 Sad' },
{ value: 'angry', label: '😠 Angry' },
{ value: 'other', label: '🤔 None of these fit' },
]
{ value: 'other', label: '🤔 None of these fit' },
]

type DebugAiTestFill = {
sentiment: SentimentValue
emotion: EntryEmotion
emotionNote: string
situation: string
details: string
}

export function LetterComposer({
onSubmit,
Expand All @@ -47,8 +59,8 @@ export function LetterComposer({
const [submittedRisk, setSubmittedRisk] = useState<RiskAssessment | null>(null)
const [dateKey, setDateKey] = useState(() => dateKeyForLocalDay(new Date()))

useEffect(() => {
const refreshDateKey = () => setDateKey(dateKeyForLocalDay(new Date()))
useEffect(() => {
const refreshDateKey = () => setDateKey(dateKeyForLocalDay(new Date()))
const intervalId = window.setInterval(refreshDateKey, 60_000)
window.addEventListener('focus', refreshDateKey)
document.addEventListener('visibilitychange', refreshDateKey)
Expand All @@ -57,7 +69,25 @@ export function LetterComposer({
window.removeEventListener('focus', refreshDateKey)
document.removeEventListener('visibilitychange', refreshDateKey)
}
}, [])
}, [])

useEffect(() => {
if (!isDebugMode()) return
const fillDebugAiTest = (event: Event) => {
const detail = (event as CustomEvent<DebugAiTestFill>).detail
if (!detail) return
setStep('write')
setSentiment(detail.sentiment)
setEmotion(detail.emotion)
setEmotionNote(detail.emotionNote)
setSituation(detail.situation)
setDetails(detail.details)
setDraftRisk(null)
setSubmitState('idle')
}
window.addEventListener('mentell:debug-ai-test-fill', fillDebugAiTest)
return () => window.removeEventListener('mentell:debug-ai-test-fill', fillDebugAiTest)
}, [])

const riskInput = useMemo(
() => ({
Expand Down Expand Up @@ -100,11 +130,12 @@ export function LetterComposer({
emotionNote: emotion === 'other' ? emotionNote.trim() : '',
situation: situation.trim(),
details: details.trim(),
flaggedTerms: finalRisk.flaggedTerms,
warningLevel: finalRisk.warningLevel,
riskScore: finalRisk.riskScore,
riskLevel: finalRisk.riskLevel,
})
flaggedTerms: finalRisk.flaggedTerms,
warningLevel: finalRisk.warningLevel,
riskScore: finalRisk.riskScore,
interventionScore: finalRisk.interventionScore,
riskLevel: finalRisk.riskLevel,
})

// Clear inputs after successful submit to make completion obvious.
setStep('write')
Expand All @@ -113,10 +144,11 @@ export function LetterComposer({
setEmotionNote('')
setSituation('')
setDetails('')
setDraftRisk(null)
setSubmitState('done')
if (finalRisk.warningLevel === 'warn' || finalRisk.responseKind) {
setSubmittedRisk(finalRisk)
}
if (finalRisk.responseKind !== 'none') {
setSubmittedRisk(finalRisk)
}
window.setTimeout(() => setSubmitState('idle'), 2200)
} catch {
setSubmitState('error')
Expand Down Expand Up @@ -244,9 +276,11 @@ export function LetterComposer({
</div>
</footer>
</section>
{submittedRisk ? (
<RiskResultModal risk={submittedRisk} onClose={() => setSubmittedRisk(null)} />
) : null}
<AnimatePresence>
{submittedRisk ? (
<RiskResultModal risk={submittedRisk} onClose={() => setSubmittedRisk(null)} />
) : null}
</AnimatePresence>
</div>
)
}
Expand All @@ -267,23 +301,28 @@ function DraftRiskNotice({ risk }: { risk: RiskAssessment }) {
)
}

function SupportNotice({ risk }: { risk: RiskAssessment }) {
const celebration = risk.responseKind === 'celebration'
return (
function SupportNotice({ risk }: { risk: RiskAssessment }) {
const celebration = risk.responseKind === 'positive'
const message =
risk.supportiveMessage ??
(celebration
? 'That sounds like something worth noticing. Keep going.'
: 'This sounds like a moment that deserves gentleness.')
return (
<div className="space-y-2">
<div className="font-medium" style={{ color: 'var(--success)' }}>
{celebration ? 'This deserves a little confetti' : 'A small note for this moment'}
</div>
<div>{risk.supportiveMessage}</div>
<div>{message}</div>
<div className="font-mono text-[11px] uppercase opacity-70">
Risk {risk.riskScore.toFixed(2)} · {risk.source}
Intervention {risk.interventionScore.toFixed(1)} · {risk.source}
</div>
</div>
)
}

function RiskNotice({ risk }: { risk: RiskAssessment }) {
const crisis = risk.riskLevel === 'crisis'
const crisis = risk.responseKind === 'crisis'
return (
<div className="space-y-2">
<div className="font-medium" style={{ color: 'var(--danger)' }}>
Expand All @@ -292,43 +331,55 @@ function RiskNotice({ risk }: { risk: RiskAssessment }) {
: 'I noticed this feels heavy. You are cared about.'}
</div>
<div>
{risk.supportiveMessage ??
(crisis
? 'If you might be in immediate danger, please contact emergency services or a crisis line now.'
: 'Consider sharing these feelings with someone you trust or a mental health professional.')}
{risk.supportiveMessage ??
(crisis
? 'If you might be in immediate danger, please contact emergency services, 988, or someone you trust now. Take one slow breath and stay near another person if you can. This moment can pass.'
: 'Consider sharing these feelings with someone you trust or a mental health professional.')}
</div>
<div className="font-mono text-[11px] uppercase opacity-70">
Risk {risk.riskScore.toFixed(2)} · {risk.source}
</div>
{risk.reasons.length ? <div>Signals: {risk.reasons.join(', ')}</div> : null}
<CrisisResourcePanel compact />
Intervention {risk.interventionScore.toFixed(1)} · {risk.source}
</div>
{risk.reasons.length ? <div>Signals: {risk.reasons.join(', ')}</div> : null}
{crisis ? <CrisisResourcePanel compact /> : null}
</div>
)
}

function RiskResultModal({ risk, onClose }: { risk: RiskAssessment; onClose: () => void }) {
const elevated = risk.warningLevel === 'warn'
const celebration = risk.responseKind === 'celebration'
const titleId = 'risk-result-modal-title'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="paper max-h-[min(90dvh,42rem)] w-full max-w-xl overflow-y-auto rounded-3xl p-6 shadow-lg"
>
function RiskResultModal({ risk, onClose }: { risk: RiskAssessment; onClose: () => void }) {
const crisis = risk.responseKind === 'crisis'
const support = risk.responseKind === 'support'
const celebration = risk.responseKind === 'positive'
const titleId = 'risk-result-modal-title'
const reduced = shouldReduceMotion()
return (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4"
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduced ? undefined : { opacity: 0 }}
transition={{ duration: motionDuration(0.2) || 0 }}
>
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="paper max-h-[min(90dvh,42rem)] w-full max-w-xl overflow-y-auto rounded-3xl p-6 shadow-lg"
initial={reduced ? false : { scale: 0.96, y: 18 }}
animate={{ scale: 1, y: 0 }}
exit={reduced ? undefined : { scale: 0.98, y: 10 }}
transition={{ duration: motionDuration(0.25) || 0 }}
>
<div className="flex items-start justify-between gap-3">
<div>
<div id={titleId} className="font-paper text-2xl">
{elevated ? 'You are not alone' : celebration ? 'Look at you go' : 'A note for you'}
{crisis ? 'You are not alone' : celebration ? 'Look at you go' : 'A note for you'}
</div>
<div className="ink-muted mt-1 text-sm">
{elevated
? 'Mentell noticed this entry may need extra care.'
: celebration
? 'Mentell noticed a bright patch worth celebrating.'
: 'Mentell found a supportive note after reading your entry.'}
{crisis
? 'Mentell noticed this entry may need extra care.'
: celebration
? 'Mentell noticed a bright patch worth celebrating.'
: 'Mentell found a supportive note after reading your entry.'}
</div>
</div>
<button
Expand All @@ -340,12 +391,12 @@ function RiskResultModal({ risk, onClose }: { risk: RiskAssessment; onClose: ()
</button>
</div>
<div className="mt-5 rounded-2xl border border-[var(--paper-border)] p-4">
{elevated ? <RiskNotice risk={risk} /> : <SupportNotice risk={risk} />}
{support || crisis ? <RiskNotice risk={risk} /> : <SupportNotice risk={risk} />}
</div>
</div>
</div>
)
}
</motion.div>
</motion.div>
)
}

function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
Expand Down
Loading