-
+
- {appTabs.map((tab) => {
- const isActive = tab.id === displayTabId
- const isExiting = tab.id === animState.exitingTabId
- const isIncoming = tab.id === animState.incomingTabId
-
- // If not part of current or animation, hide
- if (!isActive && !isExiting && !isIncoming) {
- return null
- }
-
- let opacity = 1
- let transform = 'none'
- let zIndex = 1
-
- if (isExiting) {
- opacity = animState.progress // Tied fade
- zIndex = 1
- } else if (isIncoming) {
- opacity = 1
- transform = `translateX(${animState.progress * 100 * animState.direction}%)`
- zIndex = 2
- } else if (
- isActive &&
- !animState.incomingTabId &&
- !animState.exitingTabId
- ) {
- opacity = 1
- transform = 'none'
- zIndex = 1
- } else if (
- isActive &&
- (animState.incomingTabId || animState.exitingTabId)
- ) {
- // During transition, the active tab is handled via isExiting
- return null
- }
-
- return (
-
- {renderTab(tab.id)}
-
- )
- })}
+
-
- {activeTabId &&
}
-
+
+ {activeTabId &&
}
+
)
}
export default AnimatedTabLayout
+
+const getTabTransitionDirection = (
+ previousPath: string,
+ nextPath: string
+): 1 | -1 => {
+ if (previousPath === nextPath) return 1
+
+ const previousTabId = getTabIdForPath(previousPath)
+ const nextTabId = getTabIdForPath(nextPath)
+ const previousIndex = previousTabId ? getTabIndex(previousTabId) : -1
+ const nextIndex = nextTabId ? getTabIndex(nextTabId) : -1
+
+ return previousIndex !== -1 && nextIndex !== -1 && nextIndex < previousIndex
+ ? -1
+ : 1
+}
diff --git a/src/tokens/index.ts b/src/tokens/index.ts
index 4d0656d..71f759b 100644
--- a/src/tokens/index.ts
+++ b/src/tokens/index.ts
@@ -1,7 +1,9 @@
+import type { CSSProperties } from 'react'
import type { Theme } from '../contexts/ThemeContext'
const surfaceMaxWidth = 380
const surfaceWidthPercent = 90
+const surfaceRadius = 32
export const uiTokens = {
pagePaddingX: 24,
@@ -10,19 +12,27 @@ export const uiTokens = {
sectionGap: 2,
singleVerticalSpace: 24,
doubleVerticalSpace: 48,
+ panelStackGap: 24,
+ controlRowGap: 16,
+ controlColumnGap: 10,
+ actionRowGap: 14,
+ actionContentGap: 8,
+ navItemGap: 8,
+ controlInset: 8,
surfaceWidthPercent,
surfaceMaxWidth,
+ surfaceRadius,
contentMaxWidth: surfaceMaxWidth,
timeExplorerLinkedPanelHeight: 340,
- controlRowWidth: 304,
- listItemPadding: 24,
- listItemRadius: 32,
+ controlRowWidth: surfaceMaxWidth,
+ listItemPadding: 12,
+ listItemRadius: surfaceRadius,
listItemBorderWidth: 4,
listActionHeight: 60,
listActionRadius: 20,
listUtilityActionWidth: 60,
actionButtonHeight: 88,
- actionButtonRadius: 30,
+ actionButtonRadius: surfaceRadius,
actionButtonFontSize: 28,
actionButtonIconSize: 48,
actionButtonArrowSize: 32,
@@ -39,7 +49,7 @@ export const uiTokens = {
statusIconSize: 60,
statusIconGap: 6,
outcomeContainerMinHeight: 280,
- outcomeContainerRadius: 28,
+ outcomeContainerRadius: surfaceRadius,
outcomeContainerPadding: 20,
quizOutcomeImageMaxWidth: 360,
quizOutcomeImageMaxHeight: 420,
@@ -50,15 +60,18 @@ export const uiTokens = {
stepperWidth: 72,
stepperHeight: 56,
},
-} as const
+}
-export const getSurfaceWidthConstraints = () => ({
+export const getSurfaceWidthConstraints = (): CSSProperties => ({
width: `${uiTokens.surfaceWidthPercent}%`,
maxWidth: `${uiTokens.surfaceMaxWidth}px`,
- boxSizing: 'border-box' as const,
+ boxSizing: 'border-box',
})
-export const getTopIconStyle = (theme: Theme, isSelected = false) => ({
+export const getTopIconStyle = (
+ theme: Theme,
+ isSelected = false
+): CSSProperties => ({
backgroundColor: isSelected
? theme.colors.primary
: `${theme.colors.primary}80`,
@@ -71,10 +84,13 @@ export const getTopIconStyle = (theme: Theme, isSelected = false) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
- boxSizing: 'border-box' as const,
+ boxSizing: 'border-box',
})
-export const getActionButtonStyle = (theme: Theme, baseColor: string) => {
+export const getActionButtonStyle = (
+ theme: Theme,
+ baseColor: string
+): CSSProperties => {
const isDarkTheme = theme.id === 'space'
return {
background: baseColor,
@@ -99,6 +115,6 @@ export const getActionButtonStyle = (theme: Theme, baseColor: string) => {
width: '100%',
maxWidth: `${uiTokens.surfaceMaxWidth}px`,
margin: '0 auto',
- boxSizing: 'border-box' as const,
+ boxSizing: 'border-box',
}
}
diff --git a/src/ui/definitionRowDescriptors.tsx b/src/ui/definitionRowDescriptors.tsx
index 66b086c..19cfb7f 100644
--- a/src/ui/definitionRowDescriptors.tsx
+++ b/src/ui/definitionRowDescriptors.tsx
@@ -8,6 +8,7 @@ import {
princessBuyRewardIcon,
princessSelectIcon,
} from '../assets/themes/princess/assets'
+import { getRewardImage } from '../assets/rewards/assets'
import type { ThemeId } from './themeOptions'
import type { Theme } from '../contexts/ThemeContext'
import type { ChildProfile, RewardRecord } from '../data/types'
@@ -134,67 +135,83 @@ export const createChildDefinitionListRowDescriptor = (
export const createRewardDefinitionListRowDescriptor = (
deps: RewardDefinitionDescriptorDeps
): ListRowDescriptor
=> ({
- renderItem: (reward) => (
-
-
deps.setTitleDraft(reward.id, value)}
- onCommit={(value) => deps.commitTitle(reward.id, value)}
- maxLength={80}
- baseColor={deps.theme.colors.secondary}
- inputAriaLabel="Reward name"
- transparent
- />
+ renderItem: (reward) => {
+ const rewardImage = getRewardImage(reward.imageKey)
-
- deps.updateRewardField(reward.id, {
- costStars: Math.max(0, Math.min(10, value)),
- })
- }
- min={0}
- max={10}
- />
+ return (
+
+ {rewardImage && (
+
+

+
+ )}
-
- deps.updateRewardField(reward.id, { isRepeating: value })
- }
- label="Keep available after buying"
- showLabel={false}
- showFeedback={false}
- />
-
- ),
- getPrimaryAction: (reward) => ({
- label:
- deps.activeChildStars >= reward.costStars ? 'Buy Reward' : 'Need Stars',
- icon:
- deps.activeChildStars >= reward.costStars ? (
-
deps.setTitleDraft(reward.id, value)}
+ onCommit={(value) => deps.commitTitle(reward.id, value)}
+ maxLength={80}
+ baseColor={deps.theme.colors.secondary}
+ inputAriaLabel="Reward name"
+ transparent
/>
- ) : (
- '🔒'
- ),
- disabled:
- deps.isRedeeming ||
- !deps.activeChildId ||
- deps.activeChildStars < reward.costStars,
- variant: 'primary',
- showLabel: false,
- onClick: (item) => deps.handleGiveReward(item),
- }),
+
+
+ deps.updateRewardField(reward.id, {
+ costStars: Math.max(0, Math.min(99, value)),
+ })
+ }
+ min={0}
+ max={99}
+ />
+
+
+ deps.updateRewardField(reward.id, { isRepeating: value })
+ }
+ label="Keep available after buying"
+ showLabel={false}
+ showFeedback={false}
+ />
+
+ )
+ },
+ getPrimaryAction: (reward) => {
+ const hasEnoughStars = deps.activeChildStars >= reward.costStars
+ const usesStandardGrantButton = reward.imageKey === 'yoshiEgg'
+
+ return {
+ label: hasEnoughStars ? 'Buy Reward' : 'Need Stars',
+ icon:
+ hasEnoughStars || usesStandardGrantButton ? (
+
+ ) : (
+ '🔒'
+ ),
+ disabled: deps.isRedeeming || !deps.activeChildId || !hasEnoughStars,
+ variant: 'primary',
+ showLabel: false,
+ onClick: (item) => deps.handleGiveReward(item),
+ }
+ },
})
diff --git a/src/ui/listDescriptorTypes.ts b/src/ui/listDescriptorTypes.ts
index 223417e..aca4d6c 100644
--- a/src/ui/listDescriptorTypes.ts
+++ b/src/ui/listDescriptorTypes.ts
@@ -34,7 +34,7 @@ export type ResolvedListUtilityAction = ResolvedListAction & {
export type ListRowDescriptor = {
renderItem: (item: T) => ReactNode
getPrimaryAction: (item: T) => ResolvedListAction
- getUtilityAction?: (item: T) => ResolvedListUtilityAction
+ getUtilityAction?: (item: T) => ResolvedListUtilityAction | undefined
getStarCount?: (item: T) => number | undefined
isHighlighted?: (item: T) => boolean
}
@@ -65,6 +65,7 @@ export const toStandardActionListDescriptor = (
icon: (item) => descriptor.getUtilityAction?.(item)?.icon,
disabled: (item) =>
descriptor.getUtilityAction?.(item)?.disabled ?? false,
+ hideButton: (item) => !descriptor.getUtilityAction?.(item),
variant: (item) =>
descriptor.getUtilityAction?.(item)?.variant ?? 'danger',
showLabel: (item) =>
diff --git a/src/ui/presetChoreRenderers.tsx b/src/ui/presetChoreRenderers.tsx
index 3911bf7..2d01fd5 100644
--- a/src/ui/presetChoreRenderers.tsx
+++ b/src/ui/presetChoreRenderers.tsx
@@ -1,6 +1,10 @@
-import { type ReactNode, lazy, Suspense } from 'react'
+import { type ComponentType, type ReactNode, lazy, Suspense } from 'react'
import type { Theme } from '../contexts/ThemeContext'
-import type { MathDifficulty, ToiletStatus, WaterLevel } from '../data/types'
+import type { ToiletStatus, WaterLevel } from '../data/types'
+import type { AlphabetTesterProps } from '../components/AlphabetTester'
+import type { ArithmeticTesterProps } from '../components/ArithmeticTester'
+import type { DinnerCountdownProps } from '../components/DinnerCountdown'
+import type { PositionalNotationProps } from '../components/PositionalNotation'
// Lazy load heavy activity components
const ArithmeticTester = lazy(() => import('../components/ArithmeticTester'))
@@ -17,81 +21,6 @@ const withSuspense = (component: ReactNode) => (
{component}
)
-type DinnerChoreRendererProps = {
- theme: Theme
- duration: number
- remaining: number
- totalBites: number
- bitesLeft: number
- starReward: number
- isTimerRunning: boolean
- plateImage?: string
- onAdjustTime: (delta: number) => void
- onAdjustBites: (delta: number) => void
- onStarsChange: (value: number) => void
- onExpire?: () => void
- isCompleted?: boolean
- completionImage?: string
- failureImage?: string
- biteCooldownSeconds?: number
- biteCooldownEndsAt?: number | null
- timerStartedAt?: number | null
- biteIcon?: string
- onBiteIconClick?: () => void
- showSetupControls?: boolean
- showStarReward?: boolean
-}
-
-type ArithmeticChoreRendererProps = {
- theme: Theme
- totalProblems: number
- starReward: number
- difficulty?: MathDifficulty
- isRunning: boolean
- isCompleted?: boolean
- isFailed?: boolean
- onAdjustProblems: (delta: number) => void
- onStarsChange: (value: number) => void
- onDifficultyChange?: (difficulty: MathDifficulty) => void
- onComplete: () => void
- onFail?: () => void
- checkTrigger?: number
- completionImage?: string
- failureImage?: string
-}
-
-type PositionalNotationChoreRendererProps = {
- theme: Theme
- totalProblems: number
- starReward: number
- isRunning: boolean
- isCompleted?: boolean
- isFailed?: boolean
- onAdjustProblems: (delta: number) => void
- onStarsChange: (value: number) => void
- onComplete: () => void
- onFail?: () => void
- checkTrigger?: number
- completionImage?: string
- failureImage?: string
-}
-
-type AlphabetChoreRendererProps = {
- theme: Theme
- totalProblems: number
- starReward: number
- isRunning: boolean
- isCompleted?: boolean
- isFailed?: boolean
- onAdjustProblems: (delta: number) => void
- onStarsChange: (value: number) => void
- onComplete: () => void
- onFail?: () => void
- checkTrigger?: number
- completionImage?: string
- failureImage?: string
-}
-
type WaterToiletChoreRendererProps = {
theme: Theme
waterLevel: WaterLevel
@@ -103,159 +32,24 @@ type WaterToiletChoreRendererProps = {
onCycleToilet: () => void
}
-export const renderDinnerChore = ({
- theme,
- duration,
- remaining,
- totalBites,
- bitesLeft,
- starReward,
- isTimerRunning,
- plateImage,
- onAdjustTime,
- onAdjustBites,
- onStarsChange,
- onExpire,
- isCompleted,
- completionImage,
- failureImage,
- biteCooldownSeconds,
- biteCooldownEndsAt,
- timerStartedAt,
- biteIcon,
- onBiteIconClick,
- showSetupControls,
- showStarReward,
-}: DinnerChoreRendererProps): ReactNode =>
- withSuspense(
-
- )
+const renderLazy = (
+ Component: ComponentType,
+ props: Props
+) => withSuspense()
-export const renderArithmeticChore = ({
- theme,
- totalProblems,
- starReward,
- difficulty,
- isRunning,
- isCompleted,
- isFailed,
- onAdjustProblems,
- onStarsChange,
- onDifficultyChange,
- onComplete,
- onFail,
- checkTrigger,
- completionImage,
- failureImage,
-}: ArithmeticChoreRendererProps): ReactNode =>
- withSuspense(
-
- )
+export const renderDinnerChore = (props: DinnerCountdownProps): ReactNode =>
+ renderLazy(DinnerCountdown, props)
-export const renderPositionalNotationChore = ({
- theme,
- totalProblems,
- starReward,
- isRunning,
- isCompleted,
- isFailed,
- onAdjustProblems,
- onStarsChange,
- onComplete,
- onFail,
- checkTrigger,
- completionImage,
- failureImage,
-}: PositionalNotationChoreRendererProps): ReactNode =>
- withSuspense(
-
- )
+export const renderArithmeticChore = (
+ props: ArithmeticTesterProps
+): ReactNode => renderLazy(ArithmeticTester, props)
-export const renderAlphabetChore = ({
- theme,
- totalProblems,
- starReward,
- isRunning,
- isCompleted,
- isFailed,
- onAdjustProblems,
- onStarsChange,
- onComplete,
- onFail,
- checkTrigger,
- completionImage,
- failureImage,
-}: AlphabetChoreRendererProps): ReactNode =>
- withSuspense(
-
- )
+export const renderPositionalNotationChore = (
+ props: PositionalNotationProps
+): ReactNode => renderLazy(PositionalNotation, props)
+
+export const renderAlphabetChore = (props: AlphabetTesterProps): ReactNode =>
+ renderLazy(AlphabetTester, props)
export const renderWaterToiletChore = ({
theme,
diff --git a/src/ui/taskTypeIcons.ts b/src/ui/taskTypeIcons.ts
new file mode 100644
index 0000000..ddb6759
--- /dev/null
+++ b/src/ui/taskTypeIcons.ts
@@ -0,0 +1,23 @@
+import {
+ princessChoresIcon,
+ princessEatingDinnerIcon,
+ princessFlaskFullImage,
+ princessMathsIcon,
+} from '../assets/themes/princess/assets'
+import type { TaskType } from '../data/types'
+
+export const getPrincessTaskTypeIcon = (taskType: TaskType) => {
+ switch (taskType) {
+ case 'eating':
+ return princessEatingDinnerIcon
+ case 'watertoiletcheck':
+ return princessFlaskFullImage
+ case 'math':
+ case 'positional-notation':
+ case 'alphabet':
+ return princessMathsIcon
+ case 'standard':
+ default:
+ return princessChoresIcon
+ }
+}
diff --git a/src/ui/themeOptions.ts b/src/ui/themeOptions.ts
index e600680..c3d0d27 100644
--- a/src/ui/themeOptions.ts
+++ b/src/ui/themeOptions.ts
@@ -27,6 +27,9 @@ const themeOptions: ThemeOption[] = [
},
]
+export const isThemeId = (value: string): value is ThemeId =>
+ themeOptions.some((option) => option.id === value)
+
// A quick lookup map for getting theme details by ID
export const THEME_ID_LOOKUP = new Map(
themeOptions.map((option) => [option.id, option])
diff --git a/src/ui/unifiedChoreDescriptorTypes.ts b/src/ui/unifiedChoreDescriptorTypes.ts
new file mode 100644
index 0000000..319fd47
--- /dev/null
+++ b/src/ui/unifiedChoreDescriptorTypes.ts
@@ -0,0 +1,48 @@
+import type { Dispatch, ReactNode, SetStateAction } from 'react'
+import type {
+ TaskEphemeralState,
+ TaskRecord,
+ TaskUpdatableFields,
+ TaskWithEphemeral,
+ TodoRecord,
+ TodoUpdatableFields,
+} from '../data/types'
+
+export type UnifiedChoreItem = TaskWithEphemeral | TodoRecord
+export type PrincessAsset = string | undefined
+
+export type UnifiedChoreDeps = {
+ theme: import('../contexts/ThemeContext').Theme
+ mode: 'manage' | 'today'
+ onUpdateTaskField?: (id: string, field: TaskUpdatableFields) => void
+ onUpdateTodoField?: (id: string, field: TodoUpdatableFields) => void
+ onUpdateEphemeral?: (id: string, patch: Partial) => void
+ onSetTitleDraft?: (id: string, value: string) => void
+ onCommitTitle?: (id: string, value: string) => void
+ onDeleteTask?: (id: string) => void
+ onDeleteTodo?: (id: string) => void
+ onEnterChore?: (item: UnifiedChoreItem) => void
+ onComplete?: (item: UnifiedChoreItem) => void
+ onFail?: (item: UnifiedChoreItem) => void
+ onReset?: (item: UnifiedChoreItem) => void
+ onStartDinner?: (item: UnifiedChoreItem | null) => void
+ onApplyBite?: (item: UnifiedChoreItem) => void
+ onExpireDinner?: (item: UnifiedChoreItem) => void
+ titleDrafts?: Record
+ activeMathId: string | null
+ activePVId: string | null
+ activeAlphabetId: string | null
+ activeDinnerId: string | null
+ activeWaterToiletId: string | null
+ mathCheckTriggers: Record
+ pvCheckTriggers: Record
+ alphabetCheckTriggers: Record
+ setMathCheckTriggers?: Dispatch>>
+ setPVCheckTriggers?: Dispatch>>
+ setAlphabetCheckTriggers?: Dispatch>>
+ biteCooldownSeconds: number
+ biteCooldownEndsAt?: number | null
+ activePrincessMealIcon?: string
+ renderDayTypeControl?: (task: TaskRecord) => ReactNode
+ hideDeleteUtility?: boolean
+}
diff --git a/src/ui/unifiedChoreDescriptors.tsx b/src/ui/unifiedChoreDescriptors.tsx
index 4737378..250b7ca 100644
--- a/src/ui/unifiedChoreDescriptors.tsx
+++ b/src/ui/unifiedChoreDescriptors.tsx
@@ -1,761 +1,50 @@
-import type { Dispatch, ReactNode, SetStateAction } from 'react'
-import ActionTextInput from '../components/ui/ActionTextInput'
-import ChoreOutcomeView from '../components/ChoreOutcomeView'
-import RepeatControl from '../components/ui/RepeatControl'
-import StarDisplay from '../components/ui/StarDisplay'
import {
princessActiveIcon,
princessBiteIcon,
- princessEatingFailImage,
- princessEatingFullImage,
princessGiveStarIcon,
- princessQuizCorrectImage,
princessMathsIcon,
- princessQuizIncorrectImage,
princessPlateImage,
} from '../assets/themes/princess/assets'
-import {
- DEFAULT_ALPHABET_PROBLEMS,
- DEFAULT_DINNER_BITES,
- DEFAULT_DINNER_DURATION_SECONDS,
- DEFAULT_MATH_PROBLEMS,
- DEFAULT_PV_PROBLEMS,
- getManageDinnerBitesLeft,
- getManageDinnerRemaining,
- getManageToiletStatus,
- getManageTaskCompletedAt,
- getManageWaterLevel,
- isAlphabetTask,
- isAlphabetTodo,
- isEatingTask,
- isEatingTodo,
- isMathTask,
- isMathTodo,
- isPositionalNotationTask,
- isPositionalNotationTodo,
- isWaterToiletTask,
- isWaterToiletTodo,
- type TaskEphemeralState,
- type TaskUpdatableFields,
- type TaskWithEphemeral,
- type TodoRecord,
- type TodoUpdatableFields,
- type TaskRecord,
-} from '../data/types'
-import type { Theme } from '../contexts/ThemeContext'
-import {
- calculateWaterToiletStars,
- getNextToiletStatus,
- getNextWaterLevel,
- getWaterToiletOutcome,
-} from '../lib/choreLogic'
-import { uiTokens } from '../tokens'
+import { isInChoreStage, shouldUseResetUtility } from './choreModeDefinitions'
import type { ListRowDescriptor } from './listDescriptorTypes'
-import {
- shouldHidePresetChoreTitle,
- isInChoreStage,
- type ChoreStage,
-} from './choreModeDefinitions'
import {
createPresetActivityPrimaryAction,
createPresetDinnerPrimaryAction,
createPresetTestPrimaryAction,
createPresetUtilityAction,
} from './presetChoreActions'
-import { getWaterToiletOutcomeImage } from './waterToiletAssets'
-import {
- renderAlphabetChore,
- renderArithmeticChore,
- renderDinnerChore,
- renderPositionalNotationChore,
- renderWaterToiletChore,
-} from './presetChoreRenderers'
+import { createUnifiedChoreState, getChoreType } from './unifiedChoreState'
+import { renderUnifiedChoreItem } from './unifiedChoreItemRenderer'
+import type {
+ UnifiedChoreDeps,
+ UnifiedChoreItem,
+} from './unifiedChoreDescriptorTypes'
-type UnifiedChoreItem = TaskWithEphemeral | TodoRecord
-
-export type UnifiedChoreDeps = {
- theme: Theme
- mode: 'manage' | 'today'
- // Actions
- onUpdateTaskField?: (id: string, field: TaskUpdatableFields) => void
- onUpdateTodoField?: (id: string, field: TodoUpdatableFields) => void
- onUpdateEphemeral?: (id: string, patch: Partial) => void
- onSetTitleDraft?: (id: string, value: string) => void
- onCommitTitle?: (id: string, value: string) => void
- onDeleteTask?: (id: string) => void
- onDeleteTodo?: (id: string) => void
- onEnterChore?: (item: TaskWithEphemeral | TodoRecord) => void
- onComplete?: (item: TaskWithEphemeral | TodoRecord) => void
- onFail?: (item: TaskWithEphemeral | TodoRecord) => void
- onReset?: (item: TaskWithEphemeral | TodoRecord) => void
- onStartDinner?: (item: TaskWithEphemeral | TodoRecord | null) => void
- onApplyBite?: (item: TaskWithEphemeral | TodoRecord) => void
- onExpireDinner?: (item: TaskWithEphemeral | TodoRecord) => void
- // State
- titleDrafts?: Record
- activeMathId: string | null
- activePVId: string | null
- activeAlphabetId: string | null
- activeDinnerId: string | null
- activeWaterToiletId: string | null
- mathCheckTriggers: Record
- pvCheckTriggers: Record
- alphabetCheckTriggers: Record
- setMathCheckTriggers?: Dispatch>>
- setPVCheckTriggers?: Dispatch>>
- setAlphabetCheckTriggers?: Dispatch>>
- biteCooldownSeconds: number
- biteCooldownEndsAt?: number | null
- activePrincessMealIcon?: string
- renderDayTypeControl?: (task: TaskRecord) => ReactNode
-}
+export type { UnifiedChoreDeps } from './unifiedChoreDescriptorTypes'
export function createUnifiedChoreDescriptor(
deps: UnifiedChoreDeps
): ListRowDescriptor {
const isManage = deps.mode === 'manage'
- const noop = () => undefined
-
- const isTaskItem = (item: UnifiedChoreItem): item is TaskWithEphemeral =>
- 'taskType' in item
-
- const getChoreType = (item: UnifiedChoreItem) =>
- isTaskItem(item) ? item.taskType : item.sourceTaskType
-
- const getWaterToiletDelta = (item: UnifiedChoreItem) => {
- if (isTaskItem(item)) {
- if (!isWaterToiletTask(item)) return item.starValue
- return calculateWaterToiletStars(
- getManageWaterLevel(item),
- getManageToiletStatus(item)
- )
- }
-
- if (!isWaterToiletTodo(item)) return item.starValue
- return calculateWaterToiletStars(item.waterLevel, item.toiletStatus)
- }
-
- const isCompleted = (item: UnifiedChoreItem) =>
- isTaskItem(item)
- ? Boolean(getManageTaskCompletedAt(item))
- : Boolean(item.completedAt)
-
- const hasActiveDinnerCooldown = (item: UnifiedChoreItem) =>
- deps.activeDinnerId === item.id &&
- typeof deps.biteCooldownEndsAt === 'number' &&
- deps.biteCooldownEndsAt > Date.now()
-
- const isDinnerAwaitingFinalCooldown = (item: UnifiedChoreItem) => {
- if (getChoreType(item) !== 'eating' || !isCompleted(item)) return false
-
- if (isTaskItem(item)) {
- return isEatingTask(item) && getManageDinnerBitesLeft(item) <= 0
- ? hasActiveDinnerCooldown(item)
- : false
- }
-
- return isEatingTodo(item) && item.dinnerBitesLeft <= 0
- ? hasActiveDinnerCooldown(item)
- : false
- }
-
- const isDinnerTimedOut = (item: UnifiedChoreItem) => {
- if (getChoreType(item) !== 'eating') return false
-
- if (isTaskItem(item)) {
- if (!isEatingTask(item)) return false
- const remaining = getManageDinnerRemaining(item)
- const startedAt = item.manageDinnerTimerStartedAt
- if (!startedAt)
- return remaining <= 0 && getManageDinnerBitesLeft(item) > 0
- const elapsed = (Date.now() - startedAt) / 1000
- return remaining - elapsed <= 0 && getManageDinnerBitesLeft(item) > 0
- }
-
- if (!isEatingTodo(item)) return false
- const remaining = item.dinnerRemainingSeconds
- const startedAt = item.dinnerTimerStartedAt
- if (!startedAt) return remaining <= 0 && item.dinnerBitesLeft > 0
- const elapsed = (Date.now() - startedAt) / 1000
- return remaining - elapsed <= 0 && item.dinnerBitesLeft > 0
- }
-
- const getStage = (item: UnifiedChoreItem): ChoreStage => {
- if (isCompleted(item) || isDinnerTimedOut(item)) {
- if (isDinnerAwaitingFinalCooldown(item)) return 'activity'
- return 'completed'
- }
-
- const id = item.id
- if (
- deps.activeMathId === id ||
- deps.activePVId === id ||
- deps.activeAlphabetId === id ||
- deps.activeDinnerId === id ||
- deps.activeWaterToiletId === id
- ) {
- return 'activity'
- }
- return 'setup'
- }
-
- const getWaterToiletRenderState = (item: UnifiedChoreItem) => {
- if (isTaskItem(item)) {
- if (!isWaterToiletTask(item)) return null
-
- const waterLevel = getManageWaterLevel(item)
- const toiletStatus = getManageToiletStatus(item)
-
- return {
- isCompleted: Boolean(item.manageWaterToiletCompletedAt),
- waterLevel,
- toiletStatus,
- starDelta: calculateWaterToiletStars(waterLevel, toiletStatus),
- onCycleWater: () =>
- deps.onUpdateEphemeral?.(item.id, {
- manageWaterLevel: getNextWaterLevel(waterLevel),
- }),
- onCycleToilet: () =>
- deps.onUpdateEphemeral?.(item.id, {
- manageToiletStatus: getNextToiletStatus(toiletStatus),
- }),
- }
- }
-
- if (!isWaterToiletTodo(item)) return null
-
- return {
- isCompleted: Boolean(item.completedAt),
- waterLevel: item.waterLevel,
- toiletStatus: item.toiletStatus,
- starDelta: calculateWaterToiletStars(item.waterLevel, item.toiletStatus),
- onCycleWater: () =>
- deps.onUpdateTodoField?.(item.id, {
- waterLevel: getNextWaterLevel(item.waterLevel),
- }),
- onCycleToilet: () =>
- deps.onUpdateTodoField?.(item.id, {
- toiletStatus: getNextToiletStatus(item.toiletStatus),
- }),
- }
- }
-
- const renderItem = (item: UnifiedChoreItem): ReactNode => {
- const stage = getStage(item)
- const hideTitle = shouldHidePresetChoreTitle(stage)
- const type = getChoreType(item)
-
- const commonContainer = (content: ReactNode) => (
-
- {!hideTitle &&
- (isManage ? (
-
deps.onSetTitleDraft?.(item.id, v)}
- onCommit={(v) => deps.onCommitTitle?.(item.id, v)}
- maxLength={80}
- baseColor={deps.theme.colors.primary}
- inputAriaLabel="Chore name"
- transparent
- />
- ) : (
-
- {item.title}
-
- ))}
- {content}
- {isManage &&
- stage === 'setup' &&
- isTaskItem(item) &&
- deps.renderDayTypeControl?.(item as TaskRecord)}
-
- )
-
- switch (type) {
- case 'standard':
- return commonContainer(
- stage === 'completed' ? (
-
- ) : isTaskItem(item) ? (
- <>
-
-
- deps.onUpdateTaskField?.(item.id, { isRepeating: v })
- }
- showLabel={false}
- showFeedback={false}
- />
-
-
-
- deps.onUpdateTaskField?.(item.id, { starValue: v || 1 })
- }
- min={1}
- max={3}
- />
-
- >
- ) : null
- )
-
- case 'eating': {
- const isActive = deps.activeDinnerId === item.id
- if (isTaskItem(item)) {
- if (!isEatingTask(item)) return null
-
- const eatingItem = item
- const isEatingCompleted = Boolean(eatingItem.manageDinnerCompletedAt)
-
- return commonContainer(
- isActive || isEatingCompleted || stage === 'setup'
- ? renderDinnerChore({
- theme: deps.theme,
- duration:
- eatingItem.dinnerDurationSeconds ??
- DEFAULT_DINNER_DURATION_SECONDS,
- remaining: getManageDinnerRemaining(eatingItem),
- totalBites:
- eatingItem.dinnerTotalBites ?? DEFAULT_DINNER_BITES,
- bitesLeft: getManageDinnerBitesLeft(eatingItem),
- starReward: eatingItem.starValue,
- isTimerRunning: isActive,
- timerStartedAt: eatingItem.manageDinnerTimerStartedAt,
- plateImage:
- deps.theme.id === 'princess'
- ? princessPlateImage
- : undefined,
- onAdjustTime: (delta) => {
- const next = Math.max(
- 5 * 60,
- Math.min(
- 30 * 60,
- (eatingItem.dinnerDurationSeconds ?? 600) + delta
- )
- )
- deps.onUpdateTaskField?.(item.id, {
- dinnerDurationSeconds: next,
- })
- deps.onUpdateEphemeral?.(item.id, {
- manageDinnerRemainingSeconds: next,
- })
- },
- onAdjustBites: (delta) => {
- const next = Math.max(
- 1,
- Math.min(16, (eatingItem.dinnerTotalBites ?? 2) + delta)
- )
- deps.onUpdateTaskField?.(item.id, {
- dinnerTotalBites: next,
- })
- deps.onUpdateEphemeral?.(item.id, {
- manageDinnerBitesLeft: next,
- })
- },
- onStarsChange: (v) =>
- deps.onUpdateTaskField?.(item.id, { starValue: v }),
- onExpire: () => deps.onExpireDinner?.(item),
- isCompleted: isEatingCompleted,
- completionImage:
- deps.theme.id === 'princess'
- ? princessQuizCorrectImage
- : undefined,
- failureImage:
- deps.theme.id === 'princess'
- ? princessQuizIncorrectImage
- : undefined,
- biteCooldownSeconds: deps.biteCooldownSeconds,
- biteCooldownEndsAt: deps.biteCooldownEndsAt,
- biteIcon:
- deps.theme.id === 'princess'
- ? deps.activePrincessMealIcon
- : undefined,
- showSetupControls: !isActive && !isEatingCompleted,
- showStarReward: !isActive && !isEatingCompleted,
- })
- : null
- )
- }
-
- if (!isEatingTodo(item)) return null
-
- const eatingItem = item
- const isEatingCompleted = Boolean(eatingItem.completedAt)
-
- return commonContainer(
- isActive || isEatingCompleted
- ? renderDinnerChore({
- theme: deps.theme,
- duration:
- eatingItem.dinnerDurationSeconds ??
- DEFAULT_DINNER_DURATION_SECONDS,
- remaining: eatingItem.dinnerRemainingSeconds,
- totalBites: eatingItem.dinnerTotalBites ?? DEFAULT_DINNER_BITES,
- bitesLeft: eatingItem.dinnerBitesLeft,
- starReward: eatingItem.starValue,
- isTimerRunning: isActive,
- timerStartedAt: eatingItem.dinnerTimerStartedAt,
- plateImage:
- deps.theme.id === 'princess' ? princessPlateImage : undefined,
- isCompleted: isEatingCompleted,
- completionImage:
- deps.theme.id === 'princess'
- ? princessEatingFullImage
- : undefined,
- failureImage:
- deps.theme.id === 'princess'
- ? princessEatingFailImage
- : undefined,
- biteCooldownSeconds: deps.biteCooldownSeconds,
- biteCooldownEndsAt: deps.biteCooldownEndsAt,
- biteIcon:
- deps.theme.id === 'princess'
- ? deps.activePrincessMealIcon
- : undefined,
- onAdjustTime: noop,
- onAdjustBites: noop,
- onStarsChange: noop,
- onExpire: () => deps.onExpireDinner?.(item),
- showSetupControls: false,
- showStarReward: false,
- })
- : null
- )
- }
-
- case 'math': {
- const isActive = deps.activeMathId === item.id
- if (isTaskItem(item)) {
- if (!isMathTask(item)) return null
-
- const mathItem = item
- const isMathCompleted = Boolean(mathItem.manageMathCompletedAt)
-
- return commonContainer(
- isActive || isMathCompleted || stage === 'setup'
- ? renderArithmeticChore({
- theme: deps.theme,
- totalProblems:
- mathItem.mathTotalProblems ?? DEFAULT_MATH_PROBLEMS,
- starReward: mathItem.starValue,
- difficulty: mathItem.mathDifficulty ?? 'easy',
- isRunning: isActive,
- isCompleted: isMathCompleted,
- isFailed: mathItem.manageMathLastOutcome === 'failure',
- onAdjustProblems: (delta) => {
- const next = Math.max(
- 1,
- Math.min(10, (mathItem.mathTotalProblems ?? 5) + delta)
- )
- deps.onUpdateTaskField?.(item.id, {
- mathTotalProblems: next,
- })
- },
- onStarsChange: (v) =>
- deps.onUpdateTaskField?.(item.id, { starValue: v }),
- onDifficultyChange: (d) =>
- deps.onUpdateTaskField?.(item.id, { mathDifficulty: d }),
- onComplete: () => deps.onComplete?.(item),
- onFail: () => deps.onFail?.(item),
- checkTrigger: deps.mathCheckTriggers[item.id] ?? 0,
- completionImage:
- deps.theme.id === 'princess'
- ? princessQuizCorrectImage
- : undefined,
- failureImage:
- deps.theme.id === 'princess'
- ? princessQuizIncorrectImage
- : undefined,
- })
- : null
- )
- }
-
- if (!isMathTodo(item)) return null
-
- const mathItem = item
- const isMathCompleted = Boolean(mathItem.completedAt)
-
- return commonContainer(
- isActive || isMathCompleted
- ? renderArithmeticChore({
- theme: deps.theme,
- totalProblems:
- mathItem.mathTotalProblems ?? DEFAULT_MATH_PROBLEMS,
- starReward: mathItem.starValue,
- difficulty: mathItem.mathDifficulty ?? 'easy',
- isRunning: isActive,
- isCompleted: isMathCompleted,
- isFailed: mathItem.mathLastOutcome === 'failure',
- onAdjustProblems: noop,
- onStarsChange: noop,
- onComplete: () => deps.onComplete?.(item),
- onFail: () => deps.onFail?.(item),
- checkTrigger: deps.mathCheckTriggers[item.id] ?? 0,
- completionImage:
- deps.theme.id === 'princess'
- ? princessQuizCorrectImage
- : undefined,
- failureImage:
- deps.theme.id === 'princess'
- ? princessQuizIncorrectImage
- : undefined,
- })
- : null
- )
- }
-
- case 'positional-notation': {
- const isActive = deps.activePVId === item.id
- if (isTaskItem(item)) {
- if (!isPositionalNotationTask(item)) return null
-
- const pvItem = item
- const isPVCompleted = Boolean(pvItem.managePVCompletedAt)
-
- return commonContainer(
- isActive || isPVCompleted || stage === 'setup'
- ? renderPositionalNotationChore({
- theme: deps.theme,
- totalProblems: pvItem.pvTotalProblems ?? DEFAULT_PV_PROBLEMS,
- starReward: pvItem.starValue,
- isRunning: isActive,
- isCompleted: isPVCompleted,
- isFailed: pvItem.managePVLastOutcome === 'failure',
- onAdjustProblems: (delta) => {
- const next = Math.max(
- 1,
- Math.min(10, (pvItem.pvTotalProblems ?? 5) + delta)
- )
- deps.onUpdateTaskField?.(item.id, { pvTotalProblems: next })
- },
- onStarsChange: (v) =>
- deps.onUpdateTaskField?.(item.id, { starValue: v }),
- onComplete: () => deps.onComplete?.(item),
- onFail: () => deps.onFail?.(item),
- checkTrigger: deps.pvCheckTriggers[item.id] ?? 0,
- completionImage:
- deps.theme.id === 'princess'
- ? princessQuizCorrectImage
- : undefined,
- failureImage:
- deps.theme.id === 'princess'
- ? princessQuizIncorrectImage
- : undefined,
- })
- : null
- )
- }
-
- if (!isPositionalNotationTodo(item)) return null
-
- const pvItem = item
- const isPVCompleted = Boolean(pvItem.completedAt)
-
- return commonContainer(
- isActive || isPVCompleted
- ? renderPositionalNotationChore({
- theme: deps.theme,
- totalProblems: pvItem.pvTotalProblems ?? DEFAULT_PV_PROBLEMS,
- starReward: pvItem.starValue,
- isRunning: isActive,
- isCompleted: isPVCompleted,
- isFailed: pvItem.pvLastOutcome === 'failure',
- onAdjustProblems: noop,
- onStarsChange: noop,
- onComplete: () => deps.onComplete?.(item),
- onFail: () => deps.onFail?.(item),
- checkTrigger: deps.pvCheckTriggers[item.id] ?? 0,
- completionImage:
- deps.theme.id === 'princess'
- ? princessQuizCorrectImage
- : undefined,
- failureImage:
- deps.theme.id === 'princess'
- ? princessQuizIncorrectImage
- : undefined,
- })
- : null
- )
- }
-
- case 'alphabet': {
- const isActive = deps.activeAlphabetId === item.id
- if (isTaskItem(item)) {
- if (!isAlphabetTask(item)) return null
-
- const alphaItem = item
- const isAlphabetCompleted = Boolean(
- alphaItem.manageAlphabetCompletedAt
- )
-
- return commonContainer(
- isActive || isAlphabetCompleted || stage === 'setup'
- ? renderAlphabetChore({
- theme: deps.theme,
- totalProblems:
- alphaItem.alphabetTotalProblems ??
- DEFAULT_ALPHABET_PROBLEMS,
- starReward: alphaItem.starValue,
- isRunning: isActive,
- isCompleted: isAlphabetCompleted,
- isFailed: alphaItem.manageAlphabetLastOutcome === 'failure',
- onAdjustProblems: (delta) => {
- const next = Math.max(
- 1,
- Math.min(
- 10,
- (alphaItem.alphabetTotalProblems ?? 5) + delta
- )
- )
- deps.onUpdateTaskField?.(item.id, {
- alphabetTotalProblems: next,
- })
- },
- onStarsChange: (v) =>
- deps.onUpdateTaskField?.(item.id, { starValue: v }),
- onComplete: () => deps.onComplete?.(item),
- onFail: () => deps.onFail?.(item),
- checkTrigger: deps.alphabetCheckTriggers[item.id] ?? 0,
- completionImage:
- deps.theme.id === 'princess'
- ? princessQuizCorrectImage
- : undefined,
- failureImage:
- deps.theme.id === 'princess'
- ? princessQuizIncorrectImage
- : undefined,
- })
- : null
- )
- }
-
- if (!isAlphabetTodo(item)) return null
-
- const alphaItem = item
- const isAlphabetCompleted = Boolean(alphaItem.completedAt)
-
- return commonContainer(
- isActive || isAlphabetCompleted
- ? renderAlphabetChore({
- theme: deps.theme,
- totalProblems:
- alphaItem.alphabetTotalProblems ?? DEFAULT_ALPHABET_PROBLEMS,
- starReward: alphaItem.starValue,
- isRunning: isActive,
- isCompleted: isAlphabetCompleted,
- isFailed: alphaItem.alphabetLastOutcome === 'failure',
- onAdjustProblems: noop,
- onStarsChange: noop,
- onComplete: () => deps.onComplete?.(item),
- onFail: () => deps.onFail?.(item),
- checkTrigger: deps.alphabetCheckTriggers[item.id] ?? 0,
- completionImage:
- deps.theme.id === 'princess'
- ? princessQuizCorrectImage
- : undefined,
- failureImage:
- deps.theme.id === 'princess'
- ? princessQuizIncorrectImage
- : undefined,
- })
- : null
- )
- }
-
- case 'watertoiletcheck': {
- const renderState = getWaterToiletRenderState(item)
- if (!renderState) return null
- const isActive = deps.activeWaterToiletId === item.id
-
- if (stage === 'completed') {
- const outcome = getWaterToiletOutcome(
- renderState.waterLevel,
- renderState.toiletStatus
- )
- return commonContainer(
-
- )
- }
-
- if (isActive) {
- return commonContainer(
- renderWaterToiletChore({
- theme: deps.theme,
- waterLevel: renderState.waterLevel,
- toiletStatus: renderState.toiletStatus,
- starDelta: renderState.starDelta,
- isInteractive: true,
- isCompleted: false,
- onCycleWater: renderState.onCycleWater,
- onCycleToilet: renderState.onCycleToilet,
- })
- )
- }
-
- // Setup: Manage shows day-type control (via commonContainer), Today shows nothing extra
- return commonContainer(null)
- }
-
- default:
- return null
- }
- }
+ const state = createUnifiedChoreState(deps)
return {
- renderItem,
+ renderItem: (item) => renderUnifiedChoreItem(deps, state, item),
getStarCount: (item) => {
if (isManage) return undefined
- const stage = getStage(item)
+ const stage = state.getStage(item)
if (isInChoreStage(stage)) return undefined
- if (getChoreType(item) === 'watertoiletcheck') {
- return getWaterToiletDelta(item)
- }
- return item.starValue
+ return getChoreType(item) === 'watertoiletcheck'
+ ? state.getWaterToiletDelta(item)
+ : item.starValue
},
- isHighlighted: (item) => getStage(item) === 'completed',
+ isHighlighted: (item) => state.getStage(item) === 'completed',
getPrimaryAction: (item) => {
- const stage = getStage(item)
+ const stage = state.getStage(item)
const type = getChoreType(item)
if (type === 'standard') {
- const isItemCompleted = isCompleted(item)
+ const isItemCompleted = state.isCompleted(item)
return {
label: isItemCompleted ? 'Done' : isManage ? 'Give' : 'Open chore',
icon: (
@@ -769,49 +58,16 @@ export function createUnifiedChoreDescriptor(
hideButton: isItemCompleted,
variant: 'primary',
showLabel: false,
- onClick: (i) => deps.onComplete?.(i),
+ onClick: (selected) => deps.onComplete?.(selected),
}
}
if (type === 'eating') {
- const isActive = deps.activeDinnerId === item.id
- const isFinished = stage === 'completed'
- const isCoolingDown =
- typeof deps.biteCooldownEndsAt === 'number' &&
- deps.biteCooldownEndsAt > Date.now()
- return createPresetDinnerPrimaryAction({
- stage,
- isTimerRunning: isActive,
- icon: !isFinished ? (
-
- ) : (
-
- ),
- disabled: isActive && isCoolingDown,
- onReset: (i) => deps.onReset?.(i),
- onBite: (i) => deps.onApplyBite?.(i),
- onStart: (i) => {
- deps.onStartDinner?.(i)
- },
- })
+ return createEatingPrimaryAction(deps, item, stage)
}
if (type === 'watertoiletcheck') {
- return createPresetActivityPrimaryAction({
+ return createPresetActivityPrimaryAction({
choreType: type,
stage,
icon: (
@@ -823,16 +79,12 @@ export function createUnifiedChoreDescriptor(
className="h-6 w-6 object-contain"
/>
),
- onReset: (i) => deps.onReset?.(i),
- onFinish: (i) => deps.onComplete?.(i),
- onStart: (i) => {
- if (deps.onEnterChore) return deps.onEnterChore(i)
- return deps.onComplete?.(i)
- },
+ onReset: (selected) => deps.onReset?.(selected),
+ onFinish: (selected) => deps.onComplete?.(selected),
+ onStart: (selected) => enterOrComplete(deps, selected),
})
}
- // Tests (Math, PV, Alphabet)
return createPresetTestPrimaryAction({
choreType: type,
stage,
@@ -843,36 +95,94 @@ export function createUnifiedChoreDescriptor(
className="h-6 w-6 object-contain"
/>
),
- onReset: (i) => deps.onReset?.(i),
- onCheck: (i) => {
- const triggerSetter =
- type === 'math'
- ? deps.setMathCheckTriggers
- : type === 'positional-notation'
- ? deps.setPVCheckTriggers
- : deps.setAlphabetCheckTriggers
- triggerSetter?.((prev) => ({
- ...prev,
- [i.id]: (prev[i.id] ?? 0) + 1,
- }))
- },
- onStart: (i) => {
- if (deps.onEnterChore) return deps.onEnterChore(i)
- return deps.onComplete?.(i)
- },
+ onReset: (selected) => deps.onReset?.(selected),
+ onCheck: (selected) => incrementCheckTrigger(deps, type, selected.id),
+ onStart: (selected) => enterOrComplete(deps, selected),
})
},
getUtilityAction: (item) => {
- const stage = getStage(item)
- return createPresetUtilityAction({
+ const stage = state.getStage(item)
+ if (deps.hideDeleteUtility && !shouldUseResetUtility(stage)) {
+ return undefined
+ }
+
+ return createPresetUtilityAction({
stage,
resetAriaLabel: 'Reset',
deleteAriaLabel: 'Delete',
- onReset: (i) => deps.onReset?.(i),
- onDelete: (i) =>
- isManage ? deps.onDeleteTask?.(i.id) : deps.onDeleteTodo?.(i.id),
+ onReset: (selected) => deps.onReset?.(selected),
+ onDelete: (selected) =>
+ isManage
+ ? deps.onDeleteTask?.(selected.id)
+ : deps.onDeleteTodo?.(selected.id),
theme: deps.theme,
})
},
}
}
+
+const createEatingPrimaryAction = (
+ deps: UnifiedChoreDeps,
+ item: UnifiedChoreItem,
+ stage: ReturnType['getStage']>
+) => {
+ const isActive = deps.activeDinnerId === item.id
+ const isFinished = stage === 'completed'
+ const isCoolingDown =
+ typeof deps.biteCooldownEndsAt === 'number' &&
+ deps.biteCooldownEndsAt > Date.now()
+
+ return createPresetDinnerPrimaryAction({
+ stage,
+ isTimerRunning: isActive,
+ icon: (
+
+ ),
+ disabled: isActive && isCoolingDown,
+ onReset: (selected) => deps.onReset?.(selected),
+ onBite: (selected) => deps.onApplyBite?.(selected),
+ onStart: (selected) => deps.onStartDinner?.(selected),
+ })
+}
+
+const eatingActionIcon = (
+ deps: UnifiedChoreDeps,
+ isActive: boolean,
+ isFinished: boolean
+) => {
+ if (isFinished) return princessPlateImage
+ if (deps.theme.id === 'princess' && isActive) {
+ return deps.activePrincessMealIcon ?? princessBiteIcon
+ }
+ return princessBiteIcon
+}
+
+const enterOrComplete = (deps: UnifiedChoreDeps, item: UnifiedChoreItem) => {
+ if (deps.onEnterChore) return deps.onEnterChore(item)
+ return deps.onComplete?.(item)
+}
+
+const incrementCheckTrigger = (
+ deps: UnifiedChoreDeps,
+ type: ReturnType,
+ id: string
+) => {
+ const setters = {
+ math: deps.setMathCheckTriggers,
+ 'positional-notation': deps.setPVCheckTriggers,
+ alphabet: deps.setAlphabetCheckTriggers,
+ }
+ const setter =
+ type === 'math' || type === 'positional-notation' || type === 'alphabet'
+ ? setters[type]
+ : undefined
+
+ setter?.((prev) => ({
+ ...prev,
+ [id]: (prev[id] ?? 0) + 1,
+ }))
+}
diff --git a/src/ui/unifiedChoreItemRenderer.tsx b/src/ui/unifiedChoreItemRenderer.tsx
new file mode 100644
index 0000000..90ba5df
--- /dev/null
+++ b/src/ui/unifiedChoreItemRenderer.tsx
@@ -0,0 +1,154 @@
+import type { ReactNode } from 'react'
+import ActionTextInput from '../components/ui/ActionTextInput'
+import ChoreOutcomeView from '../components/ChoreOutcomeView'
+import RepeatControl from '../components/ui/RepeatControl'
+import StarDisplay from '../components/ui/StarDisplay'
+import { princessQuizCorrectImage } from '../assets/themes/princess/assets'
+import { uiTokens } from '../tokens'
+import {
+ shouldHidePresetChoreTitle,
+ type ChoreStage,
+} from './choreModeDefinitions'
+import {
+ getChoreType,
+ isTaskItem,
+ isTestType,
+ type UnifiedChoreState,
+} from './unifiedChoreState'
+import { renderEatingContent } from './unifiedEatingRenderer'
+import { renderTestContent } from './unifiedTestRenderer'
+import { renderWaterToiletContent } from './unifiedWaterToiletRenderer'
+import type {
+ UnifiedChoreDeps,
+ UnifiedChoreItem,
+} from './unifiedChoreDescriptorTypes'
+
+export const renderUnifiedChoreItem = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: UnifiedChoreItem
+): ReactNode => {
+ const stage = state.getStage(item)
+ const type = getChoreType(item)
+ const content = renderChoreContent(deps, state, item, stage)
+
+ return renderChoreItemContainer(deps, item, stage, type, content)
+}
+
+const renderChoreItemContainer = (
+ deps: UnifiedChoreDeps,
+ item: UnifiedChoreItem,
+ stage: ChoreStage,
+ type: ReturnType,
+ content: ReactNode
+) => {
+ const isManage = deps.mode === 'manage'
+ const hideTitle = shouldHidePresetChoreTitle(stage)
+ const titleLabel = isTestType(type) ? 'Test Name' : 'Chore Name'
+
+ return (
+
+ {!hideTitle && renderTitle(deps, item, titleLabel)}
+ {content}
+ {isManage &&
+ stage === 'setup' &&
+ isTaskItem(item) &&
+ deps.renderDayTypeControl?.(item)}
+
+ )
+}
+
+const renderTitle = (
+ deps: UnifiedChoreDeps,
+ item: UnifiedChoreItem,
+ titleLabel: string
+) =>
+ deps.mode === 'manage' ? (
+ deps.onSetTitleDraft?.(item.id, value)}
+ onCommit={(value) => deps.onCommitTitle?.(item.id, value)}
+ maxLength={80}
+ baseColor={deps.theme.colors.primary}
+ inputAriaLabel={titleLabel}
+ transparent
+ />
+ ) : (
+
+ {item.title}
+
+ )
+
+const renderChoreContent = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: UnifiedChoreItem,
+ stage: ChoreStage
+) => {
+ const type = getChoreType(item)
+ if (type === 'standard')
+ return renderStandardContent(deps, state, item, stage)
+ if (type === 'eating') return renderEatingContent(deps, state, item, stage)
+ if (type === 'watertoiletcheck') {
+ return renderWaterToiletContent(deps, state, item, stage)
+ }
+ return renderTestContent(deps, state, item, stage)
+}
+
+const renderStandardContent = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: UnifiedChoreItem,
+ stage: ChoreStage
+) => {
+ if (stage === 'completed') {
+ return (
+
+ )
+ }
+
+ return isTaskItem(item) ? (
+ <>
+
+
+ deps.onUpdateTaskField?.(item.id, { isRepeating: value })
+ }
+ showLabel={false}
+ showFeedback={false}
+ />
+
+
+
+ deps.onUpdateTaskField?.(item.id, { starValue: value || 1 })
+ }
+ min={1}
+ max={3}
+ />
+
+ >
+ ) : null
+}
diff --git a/src/ui/unifiedChoreRenderUtils.ts b/src/ui/unifiedChoreRenderUtils.ts
new file mode 100644
index 0000000..e5b6b40
--- /dev/null
+++ b/src/ui/unifiedChoreRenderUtils.ts
@@ -0,0 +1,4 @@
+export const noop = () => undefined
+
+export const clamp = (value: number, min: number, max: number) =>
+ Math.max(min, Math.min(max, value))
diff --git a/src/ui/unifiedChoreState.ts b/src/ui/unifiedChoreState.ts
new file mode 100644
index 0000000..443eaef
--- /dev/null
+++ b/src/ui/unifiedChoreState.ts
@@ -0,0 +1,195 @@
+import {
+ getManageDinnerBitesLeft,
+ getManageDinnerRemaining,
+ getManageTaskCompletedAt,
+ getManageToiletStatus,
+ getManageWaterLevel,
+ isEatingTask,
+ isEatingTodo,
+ isWaterToiletTask,
+ isWaterToiletTodo,
+ type TaskRecord,
+ type TaskType,
+ type TaskWithEphemeral,
+ type TodoRecord,
+} from '../data/types'
+import {
+ calculateWaterToiletStars,
+ getNextToiletStatus,
+ getNextWaterLevel,
+} from '../lib/choreLogic'
+import type { ChoreStage } from './choreModeDefinitions'
+import type {
+ PrincessAsset,
+ UnifiedChoreDeps,
+ UnifiedChoreItem,
+} from './unifiedChoreDescriptorTypes'
+import {
+ princessQuizCorrectImage,
+ princessQuizIncorrectImage,
+} from '../assets/themes/princess/assets'
+
+export const isTaskItem = (item: UnifiedChoreItem): item is TaskWithEphemeral =>
+ 'taskType' in item
+
+export const getChoreType = (item: UnifiedChoreItem): TaskType =>
+ isTaskItem(item) ? item.taskType : item.sourceTaskType
+
+export const isTestType = (type: TaskType) =>
+ type === 'math' || type === 'positional-notation' || type === 'alphabet'
+
+export const createUnifiedChoreState = (deps: UnifiedChoreDeps) => {
+ const princessAsset = (asset?: string): PrincessAsset =>
+ deps.theme.id === 'princess' ? asset : undefined
+ const testOutcomeImages = () => ({
+ completionImage: princessAsset(princessQuizCorrectImage),
+ failureImage: princessAsset(princessQuizIncorrectImage),
+ })
+
+ const hasActiveDinnerCooldown = (item: UnifiedChoreItem) =>
+ deps.activeDinnerId === item.id &&
+ typeof deps.biteCooldownEndsAt === 'number' &&
+ deps.biteCooldownEndsAt > Date.now()
+
+ const isCompleted = (item: UnifiedChoreItem) =>
+ isTaskItem(item)
+ ? Boolean(getManageTaskCompletedAt(item))
+ : Boolean(item.completedAt)
+
+ const isDinnerAwaitingFinalCooldown = (item: UnifiedChoreItem) => {
+ if (getChoreType(item) !== 'eating' || !isCompleted(item)) return false
+
+ if (isTaskItem(item)) {
+ return isEatingTask(item) && getManageDinnerBitesLeft(item) <= 0
+ ? hasActiveDinnerCooldown(item)
+ : false
+ }
+
+ return isEatingTodo(item) && item.dinnerBitesLeft <= 0
+ ? hasActiveDinnerCooldown(item)
+ : false
+ }
+
+ const isDinnerTimedOut = (item: UnifiedChoreItem) => {
+ if (getChoreType(item) !== 'eating') return false
+
+ if (isTaskItem(item)) {
+ if (!isEatingTask(item)) return false
+ return hasExpiredDinnerTimer(
+ getManageDinnerRemaining(item),
+ item.manageDinnerTimerStartedAt,
+ getManageDinnerBitesLeft(item)
+ )
+ }
+
+ return isEatingTodo(item)
+ ? hasExpiredDinnerTimer(
+ item.dinnerRemainingSeconds,
+ item.dinnerTimerStartedAt,
+ item.dinnerBitesLeft
+ )
+ : false
+ }
+
+ const getStage = (item: UnifiedChoreItem): ChoreStage => {
+ if (isCompleted(item) || isDinnerTimedOut(item)) {
+ return isDinnerAwaitingFinalCooldown(item) ? 'activity' : 'completed'
+ }
+
+ return isActiveItem(deps, item.id) ? 'activity' : 'setup'
+ }
+
+ const getWaterToiletDelta = (item: UnifiedChoreItem) => {
+ if (isTaskItem(item)) {
+ if (!isWaterToiletTask(item)) return item.starValue
+ return calculateWaterToiletStars(
+ getManageWaterLevel(item),
+ getManageToiletStatus(item)
+ )
+ }
+
+ if (!isWaterToiletTodo(item)) return item.starValue
+ return calculateWaterToiletStars(item.waterLevel, item.toiletStatus)
+ }
+
+ const getWaterToiletRenderState = (item: UnifiedChoreItem) => {
+ if (isTaskItem(item)) return getTaskWaterToiletRenderState(deps, item)
+ return getTodoWaterToiletRenderState(deps, item)
+ }
+
+ return {
+ princessAsset,
+ testOutcomeImages,
+ isCompleted,
+ getStage,
+ getWaterToiletDelta,
+ getWaterToiletRenderState,
+ }
+}
+
+const hasExpiredDinnerTimer = (
+ remaining: number,
+ startedAt: number | null | undefined,
+ bitesLeft: number
+) => {
+ if (!startedAt) return remaining <= 0 && bitesLeft > 0
+ const elapsed = (Date.now() - startedAt) / 1000
+ return remaining - elapsed <= 0 && bitesLeft > 0
+}
+
+const isActiveItem = (deps: UnifiedChoreDeps, id: string) =>
+ deps.activeMathId === id ||
+ deps.activePVId === id ||
+ deps.activeAlphabetId === id ||
+ deps.activeDinnerId === id ||
+ deps.activeWaterToiletId === id
+
+const getTaskWaterToiletRenderState = (
+ deps: UnifiedChoreDeps,
+ item: TaskWithEphemeral
+) => {
+ if (!isWaterToiletTask(item)) return null
+
+ const waterLevel = getManageWaterLevel(item)
+ const toiletStatus = getManageToiletStatus(item)
+
+ return {
+ isCompleted: Boolean(item.manageWaterToiletCompletedAt),
+ waterLevel,
+ toiletStatus,
+ starDelta: calculateWaterToiletStars(waterLevel, toiletStatus),
+ onCycleWater: () =>
+ deps.onUpdateEphemeral?.(item.id, {
+ manageWaterLevel: getNextWaterLevel(waterLevel),
+ }),
+ onCycleToilet: () =>
+ deps.onUpdateEphemeral?.(item.id, {
+ manageToiletStatus: getNextToiletStatus(toiletStatus),
+ }),
+ }
+}
+
+const getTodoWaterToiletRenderState = (
+ deps: UnifiedChoreDeps,
+ item: TodoRecord
+) => {
+ if (!isWaterToiletTodo(item)) return null
+
+ return {
+ isCompleted: Boolean(item.completedAt),
+ waterLevel: item.waterLevel,
+ toiletStatus: item.toiletStatus,
+ starDelta: calculateWaterToiletStars(item.waterLevel, item.toiletStatus),
+ onCycleWater: () =>
+ deps.onUpdateTodoField?.(item.id, {
+ waterLevel: getNextWaterLevel(item.waterLevel),
+ }),
+ onCycleToilet: () =>
+ deps.onUpdateTodoField?.(item.id, {
+ toiletStatus: getNextToiletStatus(item.toiletStatus),
+ }),
+ }
+}
+
+export type UnifiedChoreState = ReturnType
+export type RenderableTaskRecord = TaskRecord
diff --git a/src/ui/unifiedEatingRenderer.tsx b/src/ui/unifiedEatingRenderer.tsx
new file mode 100644
index 0000000..4c52d6f
--- /dev/null
+++ b/src/ui/unifiedEatingRenderer.tsx
@@ -0,0 +1,128 @@
+import {
+ princessEatingFailImage,
+ princessEatingFullImage,
+ princessPlateImage,
+} from '../assets/themes/princess/assets'
+import {
+ DEFAULT_DINNER_BITES,
+ DEFAULT_DINNER_DURATION_SECONDS,
+ getManageDinnerBitesLeft,
+ getManageDinnerRemaining,
+ isEatingTask,
+ isEatingTodo,
+ type EatingTaskWithEphemeral,
+ type EatingTodo,
+} from '../data/types'
+import type { ChoreStage } from './choreModeDefinitions'
+import { renderDinnerChore } from './presetChoreRenderers'
+import type {
+ UnifiedChoreDeps,
+ UnifiedChoreItem,
+} from './unifiedChoreDescriptorTypes'
+import { isTaskItem, type UnifiedChoreState } from './unifiedChoreState'
+import { clamp, noop } from './unifiedChoreRenderUtils'
+
+export const renderEatingContent = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: UnifiedChoreItem,
+ stage: ChoreStage
+) => {
+ const isActive = deps.activeDinnerId === item.id
+ if (isTaskItem(item)) {
+ return isEatingTask(item)
+ ? renderEatingTask(deps, state, item, isActive, stage)
+ : null
+ }
+
+ return isEatingTodo(item)
+ ? renderEatingTodo(deps, state, item, isActive)
+ : null
+}
+
+const renderEatingTask = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: EatingTaskWithEphemeral,
+ isActive: boolean,
+ stage: ChoreStage
+) => {
+ const isCompleted = Boolean(item.manageDinnerCompletedAt)
+ if (!isActive && !isCompleted && stage !== 'setup') return null
+
+ return renderDinnerChore({
+ theme: deps.theme,
+ duration: item.dinnerDurationSeconds ?? DEFAULT_DINNER_DURATION_SECONDS,
+ remaining: getManageDinnerRemaining(item),
+ totalBites: item.dinnerTotalBites ?? DEFAULT_DINNER_BITES,
+ bitesLeft: getManageDinnerBitesLeft(item),
+ starReward: item.starValue,
+ isTimerRunning: isActive,
+ timerStartedAt: item.manageDinnerTimerStartedAt,
+ plateImage: state.princessAsset(princessPlateImage),
+ onAdjustTime: (delta) => {
+ const next = clamp(
+ (item.dinnerDurationSeconds ?? DEFAULT_DINNER_DURATION_SECONDS) + delta,
+ 5 * 60,
+ 30 * 60
+ )
+ deps.onUpdateTaskField?.(item.id, { dinnerDurationSeconds: next })
+ deps.onUpdateEphemeral?.(item.id, {
+ manageDinnerRemainingSeconds: next,
+ })
+ },
+ onAdjustBites: (delta) => {
+ const next = clamp(
+ (item.dinnerTotalBites ?? DEFAULT_DINNER_BITES) + delta,
+ 1,
+ 16
+ )
+ deps.onUpdateTaskField?.(item.id, { dinnerTotalBites: next })
+ deps.onUpdateEphemeral?.(item.id, { manageDinnerBitesLeft: next })
+ },
+ onStarsChange: (value) =>
+ deps.onUpdateTaskField?.(item.id, { starValue: value }),
+ onExpire: () => deps.onExpireDinner?.(item),
+ isCompleted,
+ ...state.testOutcomeImages(),
+ biteCooldownSeconds: deps.biteCooldownSeconds,
+ biteCooldownEndsAt: deps.biteCooldownEndsAt,
+ biteIcon: state.princessAsset(deps.activePrincessMealIcon),
+ showSetupControls: !isActive && !isCompleted,
+ showStarReward: !isActive && !isCompleted,
+ })
+}
+
+const renderEatingTodo = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: EatingTodo,
+ isActive: boolean
+) => {
+ const isCompleted = Boolean(item.completedAt)
+ if (!isActive && !isCompleted) return null
+
+ return renderDinnerChore({
+ theme: deps.theme,
+ duration: item.dinnerDurationSeconds ?? DEFAULT_DINNER_DURATION_SECONDS,
+ remaining: item.dinnerRemainingSeconds,
+ totalBites: item.dinnerTotalBites ?? DEFAULT_DINNER_BITES,
+ bitesLeft: item.dinnerBitesLeft,
+ starReward: item.starValue,
+ isTimerRunning: isActive,
+ timerStartedAt: item.dinnerTimerStartedAt,
+ plateImage: state.princessAsset(princessPlateImage),
+ isCompleted,
+ completionImage: state.princessAsset(princessEatingFullImage),
+ failureImage: state.princessAsset(princessEatingFailImage),
+ biteCooldownSeconds: deps.biteCooldownSeconds,
+ biteCooldownEndsAt: deps.biteCooldownEndsAt,
+ biteIcon: state.princessAsset(deps.activePrincessMealIcon),
+ onAdjustTime: noop,
+ onAdjustBites: noop,
+ onStarsChange: noop,
+ onExpire: () => deps.onExpireDinner?.(item),
+ showSetupControls: false,
+ showStarReward: false,
+ })
+}
diff --git a/src/ui/unifiedTestRenderer.tsx b/src/ui/unifiedTestRenderer.tsx
new file mode 100644
index 0000000..8b22b98
--- /dev/null
+++ b/src/ui/unifiedTestRenderer.tsx
@@ -0,0 +1,236 @@
+import {
+ DEFAULT_ALPHABET_PROBLEMS,
+ DEFAULT_MATH_PROBLEMS,
+ DEFAULT_PV_PROBLEMS,
+ isAlphabetTask,
+ isAlphabetTodo,
+ isMathTask,
+ isMathTodo,
+ isPositionalNotationTask,
+ isPositionalNotationTodo,
+ type AlphabetTaskWithEphemeral,
+ type AlphabetTodo,
+ type MathTaskWithEphemeral,
+ type MathTodo,
+ type PVTaskWithEphemeral,
+ type PositionalNotationTodo,
+ type TaskOutcome,
+} from '../data/types'
+import type { ChoreStage } from './choreModeDefinitions'
+import {
+ renderAlphabetChore,
+ renderArithmeticChore,
+ renderPositionalNotationChore,
+} from './presetChoreRenderers'
+import type {
+ UnifiedChoreDeps,
+ UnifiedChoreItem,
+} from './unifiedChoreDescriptorTypes'
+import { isTaskItem, type UnifiedChoreState } from './unifiedChoreState'
+import { clamp, noop } from './unifiedChoreRenderUtils'
+
+type TestVariant = 'math' | 'pv' | 'alphabet'
+type TestOutcome = TaskOutcome | null
+
+export const renderTestContent = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: UnifiedChoreItem,
+ stage: ChoreStage
+) => {
+ if (isTaskItem(item)) {
+ if (isMathTask(item)) return renderMathTask(deps, state, item)
+ if (isPositionalNotationTask(item)) return renderPVTask(deps, state, item)
+ if (isAlphabetTask(item)) return renderAlphabetTask(deps, state, item)
+ return null
+ }
+
+ if (stage !== 'activity' && stage !== 'completed') return null
+ if (isMathTodo(item)) return renderMathTodo(deps, state, item)
+ if (isPositionalNotationTodo(item)) return renderPVTodo(deps, state, item)
+ if (isAlphabetTodo(item)) return renderAlphabetTodo(deps, state, item)
+ return null
+}
+
+const renderMathTask = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: MathTaskWithEphemeral
+) =>
+ renderArithmeticChore({
+ theme: deps.theme,
+ totalProblems: item.mathTotalProblems ?? DEFAULT_MATH_PROBLEMS,
+ difficulty: item.mathDifficulty ?? 'easy',
+ ...createTaskActivityProps(deps, state, item, 'math'),
+ isCompleted: Boolean(item.manageMathCompletedAt),
+ isFailed: item.manageMathLastOutcome === 'failure',
+ onAdjustProblems: (delta) =>
+ updateMathProblems(deps, item, item.mathTotalProblems, delta),
+ onDifficultyChange: (difficulty) =>
+ deps.onUpdateTaskField?.(item.id, { mathDifficulty: difficulty }),
+ })
+
+const renderPVTask = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: PVTaskWithEphemeral
+) =>
+ renderPositionalNotationChore({
+ theme: deps.theme,
+ totalProblems: item.pvTotalProblems ?? DEFAULT_PV_PROBLEMS,
+ ...createTaskActivityProps(deps, state, item, 'pv'),
+ isCompleted: Boolean(item.managePVCompletedAt),
+ isFailed: item.managePVLastOutcome === 'failure',
+ onAdjustProblems: (delta) =>
+ updatePVProblems(deps, item, item.pvTotalProblems, delta),
+ })
+
+const renderAlphabetTask = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: AlphabetTaskWithEphemeral
+) =>
+ renderAlphabetChore({
+ theme: deps.theme,
+ totalProblems: item.alphabetTotalProblems ?? DEFAULT_ALPHABET_PROBLEMS,
+ ...createTaskActivityProps(deps, state, item, 'alphabet'),
+ isCompleted: Boolean(item.manageAlphabetCompletedAt),
+ isFailed: item.manageAlphabetLastOutcome === 'failure',
+ onAdjustProblems: (delta) =>
+ updateAlphabetProblems(deps, item, item.alphabetTotalProblems, delta),
+ })
+
+const renderMathTodo = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: MathTodo
+) =>
+ renderArithmeticChore({
+ theme: deps.theme,
+ totalProblems: item.mathTotalProblems ?? DEFAULT_MATH_PROBLEMS,
+ difficulty: item.mathDifficulty ?? 'easy',
+ ...createTodoActivityProps(deps, state, item, 'math', item.mathLastOutcome),
+ isCompleted: Boolean(item.completedAt),
+ onAdjustProblems: noop,
+ })
+
+const renderPVTodo = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: PositionalNotationTodo
+) =>
+ renderPositionalNotationChore({
+ theme: deps.theme,
+ totalProblems: item.pvTotalProblems ?? DEFAULT_PV_PROBLEMS,
+ ...createTodoActivityProps(deps, state, item, 'pv', item.pvLastOutcome),
+ isCompleted: Boolean(item.completedAt),
+ onAdjustProblems: noop,
+ })
+
+const renderAlphabetTodo = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: AlphabetTodo
+) =>
+ renderAlphabetChore({
+ theme: deps.theme,
+ totalProblems: item.alphabetTotalProblems ?? DEFAULT_ALPHABET_PROBLEMS,
+ ...createTodoActivityProps(
+ deps,
+ state,
+ item,
+ 'alphabet',
+ item.alphabetLastOutcome
+ ),
+ isCompleted: Boolean(item.completedAt),
+ onAdjustProblems: noop,
+ })
+
+const createTaskActivityProps = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: MathTaskWithEphemeral | PVTaskWithEphemeral | AlphabetTaskWithEphemeral,
+ variant: TestVariant
+) => ({
+ starReward: item.starValue,
+ isRunning: getActiveTestId(deps, variant) === item.id,
+ onStarsChange: (value: number) =>
+ deps.onUpdateTaskField?.(item.id, { starValue: value }),
+ onComplete: () => deps.onComplete?.(item),
+ onFail: () => deps.onFail?.(item),
+ checkTrigger: getCheckTrigger(deps, variant, item.id),
+ ...state.testOutcomeImages(),
+})
+
+const createTodoActivityProps = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: MathTodo | PositionalNotationTodo | AlphabetTodo,
+ variant: TestVariant,
+ lastOutcome: TestOutcome
+) => ({
+ starReward: item.starValue,
+ isRunning: getActiveTestId(deps, variant) === item.id,
+ isFailed: lastOutcome === 'failure',
+ onStarsChange: noop,
+ onComplete: () => deps.onComplete?.(item),
+ onFail: () => deps.onFail?.(item),
+ checkTrigger: getCheckTrigger(deps, variant, item.id),
+ ...state.testOutcomeImages(),
+})
+
+const getActiveTestId = (deps: UnifiedChoreDeps, variant: TestVariant) => {
+ const activeIds: Record = {
+ math: deps.activeMathId,
+ pv: deps.activePVId,
+ alphabet: deps.activeAlphabetId,
+ }
+ return activeIds[variant]
+}
+
+const getCheckTrigger = (
+ deps: UnifiedChoreDeps,
+ variant: TestVariant,
+ id: string
+) => {
+ const triggers: Record> = {
+ math: deps.mathCheckTriggers,
+ pv: deps.pvCheckTriggers,
+ alphabet: deps.alphabetCheckTriggers,
+ }
+ return triggers[variant][id] ?? 0
+}
+
+const updateMathProblems = (
+ deps: UnifiedChoreDeps,
+ item: MathTaskWithEphemeral,
+ current: number | undefined,
+ delta: number
+) =>
+ deps.onUpdateTaskField?.(item.id, {
+ mathTotalProblems: clamp((current ?? DEFAULT_MATH_PROBLEMS) + delta, 1, 10),
+ })
+
+const updatePVProblems = (
+ deps: UnifiedChoreDeps,
+ item: PVTaskWithEphemeral,
+ current: number | undefined,
+ delta: number
+) =>
+ deps.onUpdateTaskField?.(item.id, {
+ pvTotalProblems: clamp((current ?? DEFAULT_PV_PROBLEMS) + delta, 1, 10),
+ })
+
+const updateAlphabetProblems = (
+ deps: UnifiedChoreDeps,
+ item: AlphabetTaskWithEphemeral,
+ current: number | undefined,
+ delta: number
+) =>
+ deps.onUpdateTaskField?.(item.id, {
+ alphabetTotalProblems: clamp(
+ (current ?? DEFAULT_ALPHABET_PROBLEMS) + delta,
+ 1,
+ 10
+ ),
+ })
diff --git a/src/ui/unifiedWaterToiletRenderer.tsx b/src/ui/unifiedWaterToiletRenderer.tsx
new file mode 100644
index 0000000..1564927
--- /dev/null
+++ b/src/ui/unifiedWaterToiletRenderer.tsx
@@ -0,0 +1,46 @@
+import ChoreOutcomeView from '../components/ChoreOutcomeView'
+import { getWaterToiletOutcome } from '../lib/choreLogic'
+import type { ChoreStage } from './choreModeDefinitions'
+import { renderWaterToiletChore } from './presetChoreRenderers'
+import type {
+ UnifiedChoreDeps,
+ UnifiedChoreItem,
+} from './unifiedChoreDescriptorTypes'
+import type { UnifiedChoreState } from './unifiedChoreState'
+import { getWaterToiletOutcomeImage } from './waterToiletAssets'
+
+export const renderWaterToiletContent = (
+ deps: UnifiedChoreDeps,
+ state: UnifiedChoreState,
+ item: UnifiedChoreItem,
+ stage: ChoreStage
+) => {
+ const renderState = state.getWaterToiletRenderState(item)
+ if (!renderState) return null
+
+ if (stage === 'completed') {
+ const outcome = getWaterToiletOutcome(
+ renderState.waterLevel,
+ renderState.toiletStatus
+ )
+ return (
+
+ )
+ }
+
+ if (deps.activeWaterToiletId !== item.id) return null
+
+ return renderWaterToiletChore({
+ theme: deps.theme,
+ waterLevel: renderState.waterLevel,
+ toiletStatus: renderState.toiletStatus,
+ starDelta: renderState.starDelta,
+ isInteractive: true,
+ isCompleted: false,
+ onCycleWater: renderState.onCycleWater,
+ onCycleToilet: renderState.onCycleToilet,
+ })
+}
diff --git a/tests/unit/lib/choreParser.test.ts b/tests/unit/lib/choreParser.test.ts
index 93fa901..df985cc 100644
--- a/tests/unit/lib/choreParser.test.ts
+++ b/tests/unit/lib/choreParser.test.ts
@@ -10,11 +10,19 @@ import {
childStarsSnapshotDataSchema,
resetTodayTodosResultSchema,
rewardSnapshotDataSchema,
+ choreSnapshotDataSchema,
+ choreTodoSnapshotDataSchema,
taskSnapshotDataSchema,
+ testSnapshotDataSchema,
+ testTodoSnapshotDataSchema,
todoSnapshotDataSchema,
} from '../../../src/data/types'
import {
+ parseChoreSnapshot,
+ parseChoreTodoSnapshot,
parseTaskSnapshot,
+ parseTestSnapshot,
+ parseTestTodoSnapshot,
parseTodoSnapshot,
} from '../../../src/lib/choreParser'
@@ -173,6 +181,68 @@ describe('choreParser', () => {
toiletStatus: DEFAULT_TOILET_STATUS,
})
})
+
+ it('parses split chore and test templates through compatibility fields', () => {
+ const chore = parseChoreSnapshot('chore-1', {
+ choreType: 'eating',
+ childId: 'child-1',
+ dinnerDurationSeconds: 300,
+ dinnerTotalBites: 4,
+ })
+
+ const test = parseTestSnapshot('test-1', {
+ testType: 'math',
+ childId: 'child-1',
+ mathTotalProblems: 6,
+ mathDifficulty: 'hard',
+ })
+
+ expect(chore?.taskType).toBe('eating')
+ expect(chore).toMatchObject({
+ dinnerDurationSeconds: 300,
+ dinnerTotalBites: 4,
+ })
+ expect(test?.taskType).toBe('math')
+ expect(test).toMatchObject({
+ mathTotalProblems: 6,
+ mathDifficulty: 'hard',
+ })
+ })
+
+ it('parses split chore and test todos through compatibility fields', () => {
+ const choreTodo = parseChoreTodoSnapshot(
+ 'chore-todo-1',
+ {
+ sourceChoreId: 'chore-1',
+ sourceChoreType: 'watertoiletcheck',
+ childId: 'child-1',
+ },
+ '2026-05-31'
+ )
+
+ const testTodo = parseTestTodoSnapshot(
+ 'test-todo-1',
+ {
+ sourceTestId: 'test-1',
+ sourceTestType: 'alphabet',
+ childId: 'child-1',
+ alphabetTotalProblems: 7,
+ },
+ '2026-05-31'
+ )
+
+ expect(choreTodo).toMatchObject({
+ sourceTaskId: 'chore-1',
+ sourceTaskType: 'watertoiletcheck',
+ waterLevel: DEFAULT_WATER_LEVEL,
+ toiletStatus: DEFAULT_TOILET_STATUS,
+ })
+ expect(testTodo).toMatchObject({
+ sourceTaskId: 'test-1',
+ sourceTaskType: 'alphabet',
+ alphabetTotalProblems: 7,
+ })
+ })
})
describe('runtime schemas', () => {
@@ -188,6 +258,12 @@ describe('runtime schemas', () => {
costStars: 0,
isRepeating: false,
})
+
+ expect(
+ rewardSnapshotDataSchema.parse({ imageKey: 'yoshiEgg' })
+ ).toMatchObject({
+ imageKey: 'yoshiEgg',
+ })
})
it('applies defaults for task and todo snapshot schemas', () => {
@@ -226,6 +302,33 @@ describe('runtime schemas', () => {
waterLevel: DEFAULT_WATER_LEVEL,
toiletStatus: DEFAULT_TOILET_STATUS,
})
+
+ expect(
+ choreSnapshotDataSchema.parse({ choreType: 'eating' })
+ ).toMatchObject({
+ choreType: 'eating',
+ })
+ expect(testSnapshotDataSchema.parse({ testType: 'math' })).toMatchObject({
+ testType: 'math',
+ })
+ expect(
+ choreTodoSnapshotDataSchema.parse({
+ sourceChoreId: 'chore-1',
+ sourceChoreType: 'standard',
+ })
+ ).toMatchObject({
+ sourceChoreId: 'chore-1',
+ sourceChoreType: 'standard',
+ })
+ expect(
+ testTodoSnapshotDataSchema.parse({
+ sourceTestId: 'test-1',
+ sourceTestType: 'math',
+ })
+ ).toMatchObject({
+ sourceTestId: 'test-1',
+ sourceTestType: 'math',
+ })
})
it('parses child star snapshots and callable results', () => {