2026-10-04 19:27:36 +01:00
( ( ) => {
const data = window . VALLEY _DATA ;
2026-10-04 20:19:03 +01:00
const sync = window . ValleySync ;
2026-10-05 19:10:19 +01:00
const availability = window . ValleyAvailability ;
2026-10-05 19:54:15 +01:00
const calendar = window . ValleyCalendar ;
2026-10-04 19:27:36 +01:00
const weekdays = [ "Monday" , "Tuesday" , "Wednesday" , "Thursday" , "Friday" , "Saturday" , "Sunday" ] ;
const seasonNames = { spring : "Spring" , summer : "Summer" , fall : "Fall" , winter : "Winter" } ;
const state = {
year : Number ( localStorage . getItem ( "valley-year" ) ) || 1 ,
season : localStorage . getItem ( "valley-season" ) || "spring" ,
day : Number ( localStorage . getItem ( "valley-day" ) ) || 1 ,
filter : "all" ,
checks : { } ,
2026-10-06 10:07:21 +01:00
plantings : { } ,
2026-10-04 19:27:36 +01:00
revision : - 1 ,
2026-10-06 10:07:21 +01:00
saving : new Set ( ) ,
savingPlantings : new Set ( )
2026-10-04 19:27:36 +01:00
} ;
const $ = ( selector , root = document ) => root . querySelector ( selector ) ;
const $$ = ( selector , root = document ) => [ ... root . querySelectorAll ( selector ) ] ;
const escapeHtml = ( value ) => String ( value ) . replace ( /[&<>'"]/g , char => ( { "&" : "&" , "<" : "<" , ">" : ">" , "'" : "'" , '"' : """ } [ char ] ) ) ;
const itemKey = crop => crop . sharedId || crop . id ;
2026-10-06 10:07:21 +01:00
const cropFor = key => data . crops . find ( crop => itemKey ( crop ) === key ) ;
// Plantings are stored apart from checks so "on the way" never counts toward
// bundle completion. Crops planted before 1.5 have no recorded day.
const plantingFor = key => ( ! state . checks [ key ] && state . plantings [ key ] ) || null ;
const selectedDate = ( ) => ( { year : state . year , season : state . season , day : state . day } ) ;
const formatDate = date => ` ${ seasonNames [ date . season ] } ${ date . day } ${ date . year !== state . year ? ` (Y ${ date . year } ) ` : "" } ` ;
function harvestInfo ( key , grow ) {
const planted = plantingFor ( key ) ;
if ( ! planted ? . day ) return null ;
const date = calendar . harvestDate ( planted , grow ) ;
const survives = date . season === planted . season || data . crops . some ( crop => itemKey ( crop ) === key && crop . season === date . season ) ;
return { planted , date , survives , daysLeft : calendar . daysBetween ( selectedDate ( ) , date ) } ;
}
2026-10-04 19:27:36 +01:00
function saveDate ( ) {
localStorage . setItem ( "valley-year" , state . year ) ;
localStorage . setItem ( "valley-season" , state . season ) ;
localStorage . setItem ( "valley-day" , state . day ) ;
}
function weekday ( day = state . day ) { return weekdays [ ( day - 1 ) % 7 ] ; }
function setDate ( { year = state . year , season = state . season , day = state . day } ) {
state . year = Math . max ( 1 , Math . min ( 2 , year ) ) ;
state . season = data . seasons . includes ( season ) ? season : "spring" ;
state . day = Math . max ( 1 , Math . min ( 28 , day ) ) ;
saveDate ( ) ;
renderDate ( ) ;
renderToday ( ) ;
renderCrops ( ) ;
2026-10-05 19:10:19 +01:00
renderBundles ( ) ;
2026-10-04 19:27:36 +01:00
}
function moveDay ( delta ) {
let index = data . seasons . indexOf ( state . season ) ;
let day = state . day + delta ;
let year = state . year ;
if ( day > 28 ) { day = 1 ; index += 1 ; }
if ( day < 1 ) { day = 28 ; index -= 1 ; }
if ( index > 3 ) { index = 0 ; year += 1 ; }
if ( index < 0 ) { index = 3 ; year -= 1 ; }
if ( year < 1 || year > 2 ) return ;
setDate ( { year , season : data . seasons [ index ] , day } ) ;
}
function renderDate ( ) {
document . body . dataset . season = state . season ;
$ ( "#season-title" ) . textContent = seasonNames [ state . season ] ;
$ ( "#day-title" ) . textContent = state . day ;
$ ( "#weekday-title" ) . textContent = ` ${ weekday ( ) } · Year ${ state . year } ` ;
$$ ( "[data-year]" ) . forEach ( button => button . setAttribute ( "aria-pressed" , String ( Number ( button . dataset . year ) === state . year ) ) ) ;
$$ ( "[data-season]" ) . forEach ( button => button . setAttribute ( "aria-selected" , String ( button . dataset . season === state . season ) ) ) ;
$ ( "#day-grid" ) . innerHTML = Array . from ( { length : 28 } , ( _ , index ) => {
const day = index + 1 ;
2026-10-05 19:54:15 +01:00
const birthday = birthdayOn ( day ) ;
const label = ` ${ seasonNames [ state . season ] } ${ day } , ${ weekday ( day ) } ${ birthday ? ` , ${ birthday } 's birthday ` : "" } ` ;
const marker = birthday ? ` <span class="birthday-marker" title=" ${ escapeHtml ( ` ${ birthday } 's birthday ` ) } " aria-hidden="true">🎂</span> ` : "" ;
return ` <button type="button" class=" ${ day === state . day ? "selected" : "" } " data-day=" ${ day } " aria-label=" ${ escapeHtml ( label ) } " aria-pressed=" ${ day === state . day } "><small> ${ weekdays [ index % 7 ] . slice ( 0 , 2 ) } </small><span class="day-number"> ${ day } </span> ${ marker } </button> ` ;
2026-10-04 19:27:36 +01:00
} ) . join ( "" ) ;
}
2026-10-05 19:54:15 +01:00
function birthdayOn ( day ) {
return calendar . birthdayOn ( data . birthdays , state . season , day , state . year ) ;
}
2026-10-04 19:27:36 +01:00
function currentBirthday ( ) {
2026-10-05 19:54:15 +01:00
return birthdayOn ( state . day ) ;
2026-10-04 19:27:36 +01:00
}
function currentEvent ( ) {
const entry = data . events [ state . season ] [ state . day ] ;
if ( ! entry ) return null ;
if ( entry [ 2 ] && entry [ 2 ] !== state . year ) return null ;
return entry ;
}
function reminders ( ) {
const name = weekday ( ) ;
const result = [ ] ;
if ( name === "Friday" || name === "Sunday" ) result . push ( [ "Traveling Cart" , "Cindersap Forest, 6am– 8pm. Check for off-season bundle items." , "cart" ] ) ;
if ( name === "Sunday" ) result . push ( [ "The Queen of Sauce" , "Watch TV for a new recipe during Years 1– 2." , "tv" ] ) ;
if ( name === "Wednesday" ) result . push ( [ "Recipe rerun" , "The Queen of Sauce repeats a recipe you may have missed." , "tv" ] ) ;
if ( name === "Monday" && ( state . year > 1 || state . season !== "spring" || state . day >= 2 ) ) result . push ( [ "New week" , "Special Order boards refresh on Monday once unlocked." , "board" ] ) ;
return result ;
}
function cropDeadlineCards ( ) {
return data . crops . filter ( crop => crop . season === state . season && crop . last === state . day && ( ! crop . year || crop . year === state . year ) ) ;
}
2026-10-04 20:19:03 +01:00
function dailyId ( kind , title ) {
const slug = title . toLowerCase ( ) . replace ( /[^a-z0-9]+/g , "-" ) . replace ( /^-|-$/g , "" ) . slice ( 0 , 36 ) ;
return ` daily- ${ state . year } - ${ state . season } - ${ state . day } - ${ kind } - ${ slug } ` ;
}
2026-10-05 19:54:15 +01:00
function giftGuide ( name ) {
const profile = data . giftProfiles [ name ] ;
if ( ! profile ) return "" ;
const giftList = entries => entries . map ( entry => ` <li> ${ escapeHtml ( entry ) } </li> ` ) . join ( "" ) ;
return ` <div class="gift-guide" aria-label="Gift preferences for ${ escapeHtml ( name ) } ">
< div > < strong > < span aria - hidden = "true" > ♥ < / s p a n > L o v e s < / s t r o n g > < u l > $ { g i f t L i s t ( p r o f i l e . l o v e s ) } < / u l > < / d i v >
< div > < strong > < span aria - hidden = "true" > × < / s p a n > A v o i d < / s t r o n g > < u l > $ { g i f t L i s t ( p r o f i l e . a v o i d ) } < / u l > < / d i v >
< / d i v > ` ;
}
function card ( kind , label , title , copy , itemId = null , extra = "" ) {
2026-10-04 20:19:03 +01:00
const checked = itemId ? Boolean ( state . checks [ itemId ] ) : false ;
2026-10-05 19:54:15 +01:00
const content = ` ${ itemId ? ` <input type="checkbox" data-check-id=" ${ itemId } " aria-label="Mark ${ escapeHtml ( title ) } complete" ${ checked ? "checked" : "" } ${ state . saving . has ( itemId ) ? "disabled" : "" } ><span class="checkmark" aria-hidden="true"></span> ` : "" } <div class="card-label"><span></span> ${ escapeHtml ( label ) } </div><h3> ${ escapeHtml ( title ) } </h3><p> ${ escapeHtml ( copy ) } </p> ${ extra } ` ;
2026-10-04 20:19:03 +01:00
return itemId
? ` <label class="today-card ${ kind } ${ checked ? "complete" : "" } "> ${ content } </label> `
: ` <article class="today-card ${ kind } "> ${ content } </article> ` ;
2026-10-04 19:27:36 +01:00
}
function renderToday ( ) {
const cards = [ ] ;
const birthday = currentBirthday ( ) ;
const event = currentEvent ( ) ;
2026-10-05 19:54:15 +01:00
if ( birthday ) cards . push ( card ( "birthday" , "Birthday" , birthday , "A birthday gift gives 8× the usual friendship effect." , dailyId ( "birthday" , birthday ) , giftGuide ( birthday ) ) ) ;
2026-10-04 20:19:03 +01:00
if ( event ) cards . push ( card ( "event" , "Event" , event [ 0 ] , event [ 1 ] , dailyId ( "event" , event [ 0 ] ) ) ) ;
reminders ( ) . forEach ( ( [ title , copy ] ) => cards . push ( card ( "reminder" , "Reminder" , title , copy , dailyId ( "reminder" , title ) ) ) ) ;
2026-10-06 10:07:21 +01:00
const harvestKeys = new Set ( ) ;
data . crops . forEach ( crop => {
const key = itemKey ( crop ) ;
const harvest = harvestKeys . has ( key ) ? null : harvestInfo ( key , crop . grow ) ;
harvestKeys . add ( key ) ;
if ( harvest ? . survives && harvest . daysLeft === 0 ) cards . push ( card ( "harvest" , "Harvest today" , crop . name , ` Planted ${ formatDate ( harvest . planted ) } · ${ crop . purpose } ` ) ) ;
} ) ;
2026-10-05 22:56:06 +01:00
cropDeadlineCards ( ) . forEach ( crop => {
2026-10-06 10:07:21 +01:00
const planted = Boolean ( plantingFor ( itemKey ( crop ) ) ) ;
2026-10-05 22:56:06 +01:00
cards . push ( card ( planted ? "deadline planted" : "deadline" , planted ? "Planted · on the way" : "Plant by today" , crop . name , crop . purpose , itemKey ( crop ) ) ) ;
} ) ;
2026-10-04 19:27:36 +01:00
if ( ! cards . length ) cards . push ( card ( "quiet" , "Open day" , "Make your own luck" , "No fixed birthdays, festivals, or last planting dates today." ) ) ;
$ ( "#today-cards" ) . innerHTML = cards . join ( "" ) ;
const noun = cards . length === 1 ? "thing" : "things" ;
2026-10-04 20:19:03 +01:00
$ ( "#today-summary" ) . textContent = ` ${ cards . length } ${ noun } on the calendar · tick cards when done ` ;
2026-10-04 19:27:36 +01:00
}
function renderCrops ( ) {
const crops = data . crops . filter ( crop => crop . season === state . season && ( ! crop . year || crop . year === state . year ) ) ;
const rows = crops . map ( crop => {
const key = itemKey ( crop ) ;
const checked = Boolean ( state . checks [ key ] ) ;
2026-10-06 10:07:21 +01:00
const planted = Boolean ( plantingFor ( key ) ) ;
const harvest = harvestInfo ( key , crop . grow ) ;
2026-10-05 22:56:06 +01:00
const overdue = state . day > crop . last && ! checked && ! planted ;
const today = state . day === crop . last && ! checked && ! planted ;
2026-10-06 10:07:21 +01:00
const plantButton = checked ? "" : ` <button type="button" class="plant-toggle" data-plant-id=" ${ key } " aria-pressed=" ${ planted } " aria-label=" ${ planted ? ` Unmark ${ escapeHtml ( crop . name ) } as planted ` : ` Mark ${ escapeHtml ( crop . name ) } planted on ${ formatDate ( selectedDate ( ) ) } ` } " ${ state . savingPlantings . has ( key ) ? "disabled" : "" } ><span aria-hidden="true">🌱</span> ${ planted ? "Planted" : "Plant" } </button> ` ;
let growTime = ` ${ crop . grow } days<small>to grow</small> ` ;
let date = ` ${ overdue ? "Passed" : ` ${ seasonNames [ crop . season ] } ${ crop . last } ` } <small> ${ overdue ? "for this season" : "plant by" } </small> ` ;
let dateClass = today ? "today" : "" ;
if ( planted && ! harvest ) date = ` On the way<small>planting day unknown</small> ` ;
else if ( harvest && ! harvest . survives ) {
date = ` Won’ t ripen<small>season ends first</small> ` ;
dateClass = "wilts" ;
} else if ( harvest ) {
const ready = harvest . daysLeft <= 0 ;
growTime = ready ? ` Ready<small>to harvest</small> ` : ` ${ harvest . daysLeft } ${ harvest . daysLeft === 1 ? "day" : "days" } <small>to harvest</small> ` ;
date = ` ${ harvest . daysLeft === 0 ? "Today" : ready ? "Ready" : formatDate ( harvest . date ) } <small> ${ harvest . daysLeft < 0 ? ` since ${ formatDate ( harvest . date ) } ` : "harvest" } </small> ` ;
if ( ready ) dateClass = "ready" ;
}
const purpose = harvest ? ` Planted ${ formatDate ( harvest . planted ) } · ${ crop . purpose } ` : crop . purpose ;
2026-10-05 22:56:06 +01:00
return ` <div class="crop-row ${ checked ? "complete" : "" } ${ overdue ? "overdue" : "" } ${ planted ? "planted" : "" } ">
2026-10-05 19:54:15 +01:00
< input type = "checkbox" data - check - id = "${key}" aria - label = "Mark ${escapeHtml(crop.name)} complete" $ { checked ? "checked" : "" } $ { state . saving . has ( key ) ? "disabled" : "" } >
2026-10-04 19:27:36 +01:00
< span class = "checkmark" aria - hidden = "true" > < / s p a n >
2026-10-06 10:07:21 +01:00
< span class = "crop-name" > < strong > $ { escapeHtml ( crop . name ) } < / s t r o n g > < s m a l l > $ { e s c a p e H t m l ( p u r p o s e ) } < / s m a l l > < / s p a n >
2026-10-05 22:56:06 +01:00
< span class = "plant-slot" > $ { plantButton } < / s p a n >
2026-10-06 10:07:21 +01:00
< span class = "grow-time" > $ { growTime } < / s p a n >
< span class = "plant-date ${dateClass}" > $ { date } < / s p a n >
2026-10-05 22:56:06 +01:00
< / d i v > ` ;
2026-10-04 19:27:36 +01:00
} ) ;
$ ( "#crop-list" ) . innerHTML = rows . length ? rows . join ( "" ) : ` <div class="empty-state"><span>❄</span><h3>No mandatory field crops</h3><p>Use Winter for the foraging bundle, mines, fishing, and any unfinished animal or artisan goods.</p></div> ` ;
}
function groupProgress ( group ) {
const done = group . items . filter ( entry => state . checks [ entry . id ] ) . length ;
return { done , required : group . required , complete : done >= group . required } ;
}
function bundleTotals ( ) {
return data . bundles . reduce ( ( totals , room ) => room . groups . reduce ( ( inner , group ) => {
const { done , required } = groupProgress ( group ) ;
inner . done += Math . min ( done , required ) ;
inner . required += required ;
return inner ;
} , totals ) , { done : 0 , required : 0 } ) ;
}
2026-10-05 19:10:19 +01:00
function entryStatus ( entry , group ) {
const progress = groupProgress ( group ) ;
return availability . classify ( entry , {
checked : Boolean ( state . checks [ entry . id ] ) ,
chooseGroupComplete : Boolean ( group . choose && progress . complete ) ,
season : state . season
} ) ;
}
function allEntryStatuses ( ) {
return data . bundles . flatMap ( room => room . groups . flatMap ( group =>
group . items . map ( entry => ( { entry , group , status : entryStatus ( entry , group ) } ) )
) ) ;
}
function renderPrioritySummary ( ) {
const entries = allEntryStatuses ( ) ;
const count = filter => entries . filter ( ( { status } ) => availability . matchesFilter ( status , filter ) ) . length ;
$ ( "#needed-now-count" ) . textContent = count ( "now" ) ;
$ ( "#exclusive-count" ) . textContent = count ( "exclusive" ) ;
$ ( "#weather-count" ) . textContent = count ( "weather" ) ;
$ ( "#needed-now-detail" ) . textContent = ` Available in ${ seasonNames [ state . season ] } ` ;
$ ( "#exclusive-detail" ) . textContent = ` Only in ${ seasonNames [ state . season ] } ` ;
}
function seasonList ( seasons ) {
return seasons . map ( season => seasonNames [ season ] ) . join ( " + " ) ;
}
2026-10-05 22:56:06 +01:00
function statusBadges ( status , entry ) {
2026-10-05 19:10:19 +01:00
if ( status . checked ) return "" ;
const badges = [ ] ;
2026-10-06 10:07:21 +01:00
if ( plantingFor ( entry . id ) ) {
const crop = cropFor ( entry . id ) ;
const harvest = crop ? harvestInfo ( entry . id , crop . grow ) : null ;
const label = ! harvest ? . survives ? "Planted" : harvest . daysLeft <= 0 ? "Ready to harvest" : ` Ready ${ formatDate ( harvest . date ) } ` ;
badges . push ( ` <span class="status-badge planted">🌱 ${ label } </span> ` ) ;
}
2026-10-05 19:10:19 +01:00
if ( ! status . needed ) badges . push ( ` <span class="status-badge optional">Bundle ready</span> ` ) ;
else if ( status . seasonExclusive ) badges . push ( ` <span class="status-badge exclusive">Only ${ seasonNames [ state . season ] } </span> ` ) ;
else if ( status . inSeason ) badges . push ( ` <span class="status-badge in-season">In season</span> ` ) ;
else if ( status . seasonal ) badges . push ( ` <span class="status-badge later"> ${ seasonList ( status . seasons ) } </span> ` ) ;
if ( status . weather ) badges . push ( ` <span class="status-badge weather ${ status . weather . toLowerCase ( ) } "> ${ status . weather === "Rain" ? "Rain needed" : "Sunny only" } </span> ` ) ;
return badges . length ? ` <span class="item-badges"> ${ badges . join ( "" ) } </span> ` : "" ;
}
2026-10-04 19:27:36 +01:00
function renderBundles ( ) {
const html = data . bundles . map ( room => {
const groups = room . groups . map ( group => {
const progress = groupProgress ( group ) ;
let items = group . items ;
2026-10-05 19:10:19 +01:00
if ( state . filter !== "all" ) items = items . filter ( entry => availability . matchesFilter ( entryStatus ( entry , group ) , state . filter ) ) ;
2026-10-04 19:27:36 +01:00
if ( ! items . length ) return "" ;
return ` <article class="bundle-card ${ progress . complete ? "complete" : "" } ">
< header > < div > < h4 > $ { escapeHtml ( group . name ) } < / h 4 > $ { g r o u p . c h o o s e ? ` < s m a l l > C h o o s e $ { g r o u p . r e q u i r e d } < / s m a l l > ` : " " } < / d i v > < s p a n > $ { M a t h . m i n ( p r o g r e s s . d o n e , p r o g r e s s . r e q u i r e d ) } / $ { p r o g r e s s . r e q u i r e d } < / s p a n > < / h e a d e r >
< div class = "bundle-items" > $ { items . map ( entry => {
const checked = Boolean ( state . checks [ entry . id ] ) ;
2026-10-05 19:10:19 +01:00
const status = entryStatus ( entry , group ) ;
2026-10-06 10:07:21 +01:00
const classes = [ checked ? "complete" : "" , status . needed ? "needed" : "optional" , status . needed && status . availableNow ? "available-now" : "" , status . seasonExclusive ? "season-exclusive" : "" , status . weather ? "weather-specific" : "" , plantingFor ( entry . id ) ? "planted" : "" ] . filter ( Boolean ) . join ( " " ) ;
2026-10-05 22:56:06 +01:00
return ` <label class="bundle-item ${ classes } "><input type="checkbox" data-check-id=" ${ entry . id } " aria-label="Mark ${ escapeHtml ( entry . label ) } complete" ${ checked ? "checked" : "" } ${ state . saving . has ( entry . id ) ? "disabled" : "" } ><span class="checkmark" aria-hidden="true"></span><span class="item-copy"><strong> ${ escapeHtml ( entry . label ) } </strong><small> ${ escapeHtml ( entry . meta ) } </small> ${ statusBadges ( status , entry ) } </span></label> ` ;
2026-10-04 19:27:36 +01:00
} ) . join ( "" ) } < / d i v >
< / a r t i c l e > ` ;
} ) . filter ( Boolean ) . join ( "" ) ;
if ( ! groups ) return "" ;
const roomGroups = room . groups . map ( groupProgress ) ;
const roomDone = roomGroups . filter ( progress => progress . complete ) . length ;
return ` <section class="room"><div class="room-title"><span> ${ room . icon } </span><div><h3> ${ escapeHtml ( room . room ) } </h3><p> ${ roomDone } of ${ room . groups . length } bundles ready</p></div></div><div class="room-grid"> ${ groups } </div></section> ` ;
} ) . filter ( Boolean ) . join ( "" ) ;
2026-10-05 19:10:19 +01:00
const emptyMessages = {
now : [ "Nothing needed right now" , ` No unfinished bundle items are available from normal sources in ${ seasonNames [ state . season ] } . ` ] ,
exclusive : [ "No season-only items left" , ` You have covered every unfinished item exclusive to ${ seasonNames [ state . season ] } . ` ] ,
weather : [ "No weather windows left" , ` No unfinished fish in ${ seasonNames [ state . season ] } require rain or sun. ` ] ,
open : [ "Community Center covered" , "No required bundle items remain." ] ,
all : [ "Everything in this view is done" , "Switch filters to review the whole Community Center." ]
} ;
const empty = emptyMessages [ state . filter ] || emptyMessages . all ;
$ ( "#bundle-list" ) . innerHTML = html || ` <div class="empty-state"><span>✓</span><h3> ${ empty [ 0 ] } </h3><p> ${ empty [ 1 ] } </p></div> ` ;
renderPrioritySummary ( ) ;
2026-10-04 19:27:36 +01:00
const totals = bundleTotals ( ) ;
const percent = totals . required ? Math . round ( totals . done / totals . required * 100 ) : 0 ;
$ ( "#progress-label" ) . textContent = ` ${ totals . done } of ${ totals . required } essentials ` ;
$ ( "#progress-bar" ) . style . width = ` ${ percent } % ` ;
}
function setSyncStatus ( mode , text ) {
const node = $ ( "#sync-status" ) ;
node . dataset . mode = mode ;
node . lastElementChild . textContent = text ;
}
function toast ( message ) {
const node = $ ( "#toast" ) ;
node . textContent = message ;
node . classList . add ( "show" ) ;
clearTimeout ( toast . timer ) ;
toast . timer = setTimeout ( ( ) => node . classList . remove ( "show" ) , 2600 ) ;
}
async function loadState ( { quiet = false } = { } ) {
if ( ! quiet ) setSyncStatus ( "loading" , "Connecting…" ) ;
try {
const response = await fetch ( "/api/state" , { cache : "no-store" } ) ;
if ( ! response . ok ) throw new Error ( ` State request failed ( ${ response . status } ) ` ) ;
const snapshot = await response . json ( ) ;
2026-10-06 10:07:21 +01:00
const merged = sync . mergeSnapshot ( state . checks , state . saving , state . revision , snapshot , state . plantings , state . savingPlantings ) ;
2026-10-04 20:19:03 +01:00
if ( merged . applied ) {
state . checks = merged . checks ;
2026-10-06 10:07:21 +01:00
state . plantings = merged . plantings ;
2026-10-04 20:19:03 +01:00
state . revision = merged . revision ;
renderToday ( ) ;
2026-10-04 19:27:36 +01:00
renderCrops ( ) ;
renderBundles ( ) ;
}
setSyncStatus ( "ready" , "Shared progress synced" ) ;
} catch ( error ) {
console . error ( error ) ;
setSyncStatus ( "error" , "Sync unavailable" ) ;
}
}
async function updateCheck ( itemId , checked ) {
const previous = Boolean ( state . checks [ itemId ] ) ;
state . checks [ itemId ] = checked ;
state . saving . add ( itemId ) ;
2026-10-04 20:19:03 +01:00
renderToday ( ) ;
2026-10-04 19:27:36 +01:00
renderCrops ( ) ;
renderBundles ( ) ;
setSyncStatus ( "loading" , "Saving…" ) ;
try {
const response = await fetch ( "/api/checks" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { itemId , checked } )
} ) ;
if ( ! response . ok ) throw new Error ( ` Save failed ( ${ response . status } ) ` ) ;
const result = await response . json ( ) ;
2026-10-04 20:19:03 +01:00
const confirmed = sync . confirmWrite ( state . checks , state . revision , result ) ;
state . checks = confirmed . checks ;
state . revision = confirmed . revision ;
2026-10-04 19:27:36 +01:00
setSyncStatus ( "ready" , "Shared progress synced" ) ;
} catch ( error ) {
console . error ( error ) ;
state . checks [ itemId ] = previous ;
setSyncStatus ( "error" , "Couldn’ t save" ) ;
toast ( "That change wasn’ t saved. Your previous shared state is restored." ) ;
2026-10-04 20:19:03 +01:00
void loadState ( { quiet : true } ) ;
2026-10-04 19:27:36 +01:00
} finally {
state . saving . delete ( itemId ) ;
2026-10-04 20:19:03 +01:00
renderToday ( ) ;
2026-10-04 19:27:36 +01:00
renderCrops ( ) ;
renderBundles ( ) ;
}
}
2026-10-06 10:07:21 +01:00
async function updatePlanting ( itemId , planted ) {
const previous = state . plantings [ itemId ] ;
if ( planted ) state . plantings [ itemId ] = planted ;
else delete state . plantings [ itemId ] ;
state . savingPlantings . add ( itemId ) ;
renderToday ( ) ;
renderCrops ( ) ;
renderBundles ( ) ;
setSyncStatus ( "loading" , "Saving…" ) ;
try {
const response = await fetch ( "/api/plantings" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { itemId , planted } )
} ) ;
if ( ! response . ok ) throw new Error ( ` Save failed ( ${ response . status } ) ` ) ;
const result = await response . json ( ) ;
const confirmed = sync . confirmPlanting ( state . plantings , state . revision , result ) ;
state . plantings = confirmed . plantings ;
state . revision = confirmed . revision ;
setSyncStatus ( "ready" , "Shared progress synced" ) ;
} catch ( error ) {
console . error ( error ) ;
if ( previous ) state . plantings [ itemId ] = previous ;
else delete state . plantings [ itemId ] ;
setSyncStatus ( "error" , "Couldn’ t save" ) ;
toast ( "That change wasn’ t saved. Your previous shared state is restored." ) ;
void loadState ( { quiet : true } ) ;
} finally {
state . savingPlantings . delete ( itemId ) ;
renderToday ( ) ;
renderCrops ( ) ;
renderBundles ( ) ;
}
}
2026-10-04 19:27:36 +01:00
function installWebMcp ( ) {
const context = document . modelContext ;
if ( ! context ? . registerTool ) return ;
const knownIds = new Set ( data . bundles . flatMap ( room => room . groups . flatMap ( group => group . items . map ( entry => entry . id ) ) ) ) ;
void Promise . resolve ( context . registerTool ( {
name : "get_valley_progress" ,
title : "Get Valley progress" ,
description : "Read the currently selected game date and shared Community Center checklist progress." ,
inputSchema : { type : "object" , properties : { } , additionalProperties : false } ,
annotations : { readOnlyHint : true , untrustedContentHint : false } ,
2026-10-06 10:07:21 +01:00
execute : ( ) => ( { year : state . year , season : state . season , day : state . day , progress : bundleTotals ( ) , completedItemIds : Object . keys ( state . checks ) . filter ( id => state . checks [ id ] ) , plantings : state . plantings } )
2026-10-04 19:27:36 +01:00
} ) ) . catch ( console . error ) ;
void Promise . resolve ( context . registerTool ( {
name : "set_valley_checklist_item" ,
title : "Update Valley checklist" ,
description : "Check or uncheck one known Community Center item in the shared household plan." ,
inputSchema : { type : "object" , properties : { itemId : { type : "string" } , checked : { type : "boolean" } } , required : [ "itemId" , "checked" ] , additionalProperties : false } ,
annotations : { readOnlyHint : false , untrustedContentHint : false } ,
async execute ( input ) {
if ( ! input || ! knownIds . has ( input . itemId ) || typeof input . checked !== "boolean" ) throw new Error ( "Unknown checklist item or invalid checked value" ) ;
await updateCheck ( input . itemId , input . checked ) ;
return { itemId : input . itemId , checked : Boolean ( state . checks [ input . itemId ] ) , revision : state . revision } ;
}
} ) ) . catch ( console . error ) ;
}
document . addEventListener ( "click" , event => {
2026-10-05 20:07:28 +01:00
const yearButton = event . target . closest ( "button[data-year]" ) ;
2026-10-04 19:27:36 +01:00
if ( yearButton ) setDate ( { year : Number ( yearButton . dataset . year ) } ) ;
2026-10-05 20:07:28 +01:00
const seasonButton = event . target . closest ( "button[data-season]" ) ;
2026-10-04 19:27:36 +01:00
if ( seasonButton ) setDate ( { season : seasonButton . dataset . season } ) ;
2026-10-05 20:07:28 +01:00
const dayButton = event . target . closest ( "button[data-day]" ) ;
2026-10-04 19:27:36 +01:00
if ( dayButton ) setDate ( { day : Number ( dayButton . dataset . day ) } ) ;
2026-10-05 20:07:28 +01:00
const filterButton = event . target . closest ( "button[data-filter]" ) ;
2026-10-04 19:27:36 +01:00
if ( filterButton ) {
state . filter = filterButton . dataset . filter ;
2026-10-05 19:10:19 +01:00
$$ ( "[data-filter]" ) . forEach ( button => button . setAttribute ( "aria-pressed" , String ( button . dataset . filter === state . filter ) ) ) ;
2026-10-04 19:27:36 +01:00
renderBundles ( ) ;
}
2026-10-05 22:56:06 +01:00
const plantButton = event . target . closest ( "button[data-plant-id]" ) ;
2026-10-06 10:07:21 +01:00
if ( plantButton && ! plantButton . disabled ) updatePlanting ( plantButton . dataset . plantId , plantingFor ( plantButton . dataset . plantId ) ? null : selectedDate ( ) ) ;
2026-10-04 19:27:36 +01:00
const navLink = event . target . closest ( ".section-nav a" ) ;
if ( navLink ) {
$$ ( ".section-nav a" ) . forEach ( link => link . classList . toggle ( "active" , link === navLink ) ) ;
}
} ) ;
document . addEventListener ( "change" , event => {
const input = event . target . closest ( "[data-check-id]" ) ;
if ( input ) updateCheck ( input . dataset . checkId , input . checked ) ;
} ) ;
$ ( "#previous-day" ) . addEventListener ( "click" , ( ) => moveDay ( - 1 ) ) ;
$ ( "#next-day" ) . addEventListener ( "click" , ( ) => moveDay ( 1 ) ) ;
renderDate ( ) ;
renderToday ( ) ;
renderCrops ( ) ;
renderBundles ( ) ;
loadState ( ) ;
setInterval ( ( ) => loadState ( { quiet : true } ) , 4000 ) ;
installWebMcp ( ) ;
} ) ( ) ;