const WEEKS_BEFORE_LAST_FROST = 10; const FALL_FACTOR = 2; function range(start, end) { const size = end - start; const array = new Array(size); for(let i = start; i < end; i++) { array[i - start] = i; } return array; } function daysLater(date, n) { return new Date(date.getTime() + n * 24 * 60 * 60 * 1000); } function weeksLater(date, n) { return daysLater(date, n * 7); } function seasonLengthInWeeks(lastFrostDate, firstFrostDate) { const oneWeek = 7 * 24 * 60 * 60 * 1000; const dt = firstFrostDate.getTime() - lastFrostDate.getTime(); return Math.floor(dt / oneWeek); } function seasonWeeks(lastFrostDate, seasonLength) { return range(-WEEKS_BEFORE_LAST_FROST, seasonLength + 1).map(offset => { return { date: weeksLater(lastFrostDate, offset), offset: offset }; }); } class Season { constructor(lastFrostDate, firstFrostDate) { this.lastFrostDate = lastFrostDate; this.firstFrostDate = firstFrostDate; this.length = seasonLengthInWeeks(lastFrostDate, firstFrostDate); this.weeks = seasonWeeks(lastFrostDate, this.length); } } class Crop { constructor(name, {fallCrop, headStart, interval, maturity, multisow, pause, spacing, springPlant, resume, species, transplant, trellis}) { // Plant again in the fall? this.fallCrop = fallCrop; // Weeks spent indoors before transplanting. this.headStart = headStart || 0; // Succession planting interval (weeks) this.interval = interval || 0; // Average time to maturity from transplanting or sowing (weeks) this.maturity = maturity; // Seeds per module this.multisow = multisow || 1; // Human readable name this.name = name; // When to pause succession planting (weeks after last frost) this.pause = pause || 99; // When to resume succession planting (weeks before first frost) this.resume = resume || 99; // Plants per square foot, or, in the case of multisowing, clumps // per square foot. this.spacing = spacing || 1; // Latin names. this.species = species || []; // Earliest sowing/transplanting week (offset relative to last // frost date.) this.springPlant = springPlant; // Transplanted or direct sown? this.transplant = transplant || false; // Needs a trellis? this.trellis = trellis || false; } } const CROPS = [ new Crop("Arugula", { interval: 2, maturity: 6, pause: 3, resume: 11, spacing: 4, species: ["eruca sativa"], springPlant: -4 }), new Crop("Basil", { headStart: 8, maturity: 6, species: ["ocimum basilicum"], springPlant: 2, transplant: true }), new Crop("Bean (Bush)", { interval: 2, maturity: 8, spacing: 9, species: ["phaseolus vulgaris"], springPlant: 1 }), new Crop("Bean (Pole)", { maturity: 9, spacing: 8, species: ["phaseolus vulgaris"], springPlant: 1, trellis: true }), new Crop("Beet", { interval: 2, maturity: 8, multisow: 4, pause: 1, resume: 12, spacing: 4, species: ["beta vulgaris"], springPlant: -3 }), new Crop("Bok Choy", { fallCrop: true, headStart: 4, maturity: 7, spacing: 4, species: ["brassica rapa"], springPlant: -2 }), new Crop("Broccoli", { fallCrop: true, headStart: 4, maturity: 12, species: ["brassica oleracea"], springPlant: -3, transplant: true }), new Crop("Brussels Sprouts", { headStart: 4, fallCrop: true, maturity: 16, species: ["brassica oleracea"], transplant: true }), new Crop("Cabbage (Early)", { headStart: 4, maturity: 6, species: ["brassica oleracea"], springPlant: -3, transplant: true }), new Crop("Cabbage (Late)", { fallCrop: true, headStart: 4, maturity: 14, species: ["brassica oleracea"], springPlant: -3, transplant: true }), new Crop("Cabbage (Asian)", { fallCrop: true, headStart: 4, maturity: 10, species: ["brassica rapa"], springPlant: -3, transplant: true }), new Crop("Cauliflower", { fallCrop: true, headStart: 4, maturity: 10, species: ["brassica oleracea"], springPlant: -3, transplant: true }), new Crop("Carrot", { interval: 2, maturity: 10, spacing: 16, species: ["daucus carota"], springPlant: -4 }), new Crop("Cilantro", { interval: 2, maturity: 7, species: ["coriandrum sativum"], springPlant: 0 }), new Crop("Claytonia", { fallCrop: true, maturity: 6, spacing: 9, species: ["montia perfoliata"], springPlant: -5 }), new Crop("Cucumber (Vine)", { maturity: 9, spacing: 2, species: ["cucumis sativus"], springPlant: 1, trellis: true }), new Crop("Dill", { interval: 4, maturity: 8, species: ["anethum graveolens"], springPlant: 0 }), new Crop("Eggplant", { headStart: 8, maturity: 11, species: ["solanum melongena"], springPlant: 3, transplant: true }), new Crop("Endive", { headStart: 6, maturity: 4, species: ["cichorium endivia"], springPlant: -3, transplant: true }), new Crop("Fennel", { fallCrop: 'sow', maturity: 10, species: ["foeniculum vulgare"], springPlant: 0 }), new Crop("Kale", { fallCrop: true, headStart: 4, maturity: 4, species: ["brassica napus", "brassica oleracea"], springPlant: -4, transplant: true }), new Crop("Lettuce (Leaf/Mini Head)", { headStart: 4, interval: 2, maturity: 7, pause: 3, resume: 11, spacing: 4, species: ["latuca sativa"], springPlant: -4, transplant: true }), new Crop("Mache (Corn Salad)", { interval: 2, maturity: 7, pause: 1, resume: 11, spacing: 16, species: ["valerianella locusta"], springPlant: -4 }), new Crop("Mustard (Asian Greens)", { headStart: 3, interval: 2, maturity: 6, pause: 3, resume: 10, spacing: 4, species: ["brassica juncea", "brassica rapa"], springPlant: -4, transplant: true }), new Crop("Onion", { headStart: 6, maturity: 14, multisow: 3, spacing: 4, species: ["allium cepa"], springPlant: -4, transplant: true }), new Crop("Parsley", { headStart: 6, maturity: 7, spacing: 4, species: ["petroselinum crispum"], springPlant: -4, transplant: true }), new Crop("Parsnip", { maturity: 14, spacing: 4, species: ["pastinaca sativa"], springPlant: -2 }), new Crop("Pea", { fallCrop: 'sow', maturity: 12, spacing: 8, species: ["pisum sativum"], springPlant: -4, trellis: true }), new Crop("Pepper", { headStart: 8, maturity: 10, species: ["capsicum annuum"], springPlant: 3, transplant: true }), new Crop("Potato", { maturity: 12, spacing: 4, species: ["solanum tuberosum"], springPlant: -3 }), new Crop("Radish (Round)", { interval: 2, maturity: 4, multisow: 4, pause: 6, resume: 10, spacing: 4, species: ["raphanus raphanistrum"], springPlant: -3 }), new Crop("Radish (Daikon)", { fallCrop: true, maturity: 9, spacing: 4, species: ["raphanus raphanistrum"] }), new Crop("Shallot", { headStart: 6, maturity: 9, multisow: 3, spacing: 4, species: ["allium cepa"], springPlant: -4, transplant: true }), new Crop("Spinach", { interval: 2, maturity: 6, multisow: 4, pause: -1, resume: 11, spacing: 4, species: ["spinacia olerace"], springPlant: -5 }), new Crop("Squash (Summer)", { maturity: 8, spacing: 1.0 / 9, species: ["cucurbita pepo"], springPlant: 0 }), new Crop("Squash (Winter)", { maturity: 12, spacing: 0.5, species: ["cucurbita maxima", "cucurbita moschata", "cucurbita pepo"], springPlant: 1 }), new Crop("Swiss Chard", { headStart: 4, maturity: 4, spacing: 4, species: ["beta vulgaris"], springPlant: -3, transplant: true }), new Crop("Tomato (Indeterminate)", { headStart: 7, maturity: 11, species: ["lycopersicon esculentum"], springPlant: 3, transplant: true, trellis: true }), new Crop("Turnip (Salad)", { interval: 2, maturity: 5, multisow: 4, pause: 6, resume: 10, spacing: 16, species: ["brassica rapa"], springPlant: -3 }) ]; function makeCropSchedule(crop, season) { function isSuccession(offset) { const start = offset <= crop.pause ? crop.springPlant : crop.resume; return crop.interval > 0 && offset >= crop.springPlant && offset <= fallPlant && (offset <= crop.pause || offset >= resume) && (offset - start) % crop.interval == 0; } const springStart = crop.springPlant - crop.headStart; const fallPlant = season.length - crop.maturity - FALL_FACTOR; const fallStart = fallPlant - crop.headStart; const resume = season.length - crop.resume; return season.weeks.map(week => { function action(type) { const date = weeksLater(season.lastFrostDate, week.offset); return { date: date, actions: type ? [type] : [] }; } if(crop.transplant && week.offset == springStart) { return action("start"); } else if(crop.transplant && week.offset == crop.springPlant) { return action("transplant"); } else if(week.offset == crop.springPlant) { return action("sow"); } else if(isSuccession(week.offset)) { return action("sow"); } else if(crop.fallCrop && crop.transplant && week.offset == fallStart) { return action("start"); } else if(crop.fallCrop && crop.transplant && week.offset == fallPlant) { return action("transplant"); } else if(crop.fallCrop && week.offset == fallPlant) { return action("sow"); } return action(null); }); } function clearElement(element) { while (element.firstChild) { element.removeChild(element.firstChild); } } function makeSchedule(crops, season) { const schedule = {}; crops.forEach(crop => { schedule[crop.name] = makeCropSchedule(crop, season); }); return schedule; } function monthAndDay(date) { return (date.getUTCMonth() + 1) + "/" + date.getUTCDate(); } function viewCropDetails(crop) { function close() { container.removeChild(overlay); } function addDetail(name, detail) { const p = document.createElement("p"); p.appendChild(document.createTextNode(`${name}: ${detail}`)); modal.appendChild(p); } const container = document.getElementById("container"); const overlay = document.createElement("div"); const modal = document.createElement("div"); const heading = document.createElement("h2"); const subheading = document.createElement("h4"); const speciesDetail = `(${crop.species.join(", ")})`; heading.appendChild(document.createTextNode(crop.name)); subheading.appendChild(document.createTextNode(speciesDetail)); subheading.classList.add("detail-subheading"); modal.classList.add("modal"); modal.appendChild(heading); modal.appendChild(subheading); addDetail("Average time to maturity", `${crop.maturity} weeks from ${crop.transplant ? "transplant" : "seed"}`); if(crop.springPlant == -1) { addDetail("Spring planting time", `1 week before last frost`); } else if(crop.springPlant < 0) { addDetail("Spring planting time", `${Math.abs(crop.springPlant)} weeks before last frost`); } else if(crop.springPlant == 1) { addDetail("Spring planting time", `1 week after last frost`); } else if(crop.springPlant > 0) { addDetail("Spring planting time", `${crop.springPlant} weeks after last frost`); } else if(crop.springPlant == 0) { addDetail("Spring planting time", "On the last frost date"); } else { addDetail("Spring planting time", "Not a spring crop"); } if(crop.fallCrop) { addDetail("Fall planting time", `${crop.maturity + FALL_FACTOR} weeks before first frost`); } else if(crop.resume != 99) { addDetail("Fall planting time", `Succession planted ${crop.resume} weeks before first frost`); } else if(crop.interval > 0) { addDetail("Fall planting time", `Succession planted until ${crop.maturity + FALL_FACTOR} weeks before first frost`); } else { addDetail("Fall planting time", "Not a fall crop"); } addDetail("Planting method", crop.transplant ? "Transplant" : "Direct sow"); if(crop.transplant) { addDetail("Start indoors", `${crop.headStart} weeks before planting`); } if(crop.interval > 0) { addDetail("Succession planting interval", `Every ${crop.interval} weeks`); } if(crop.multisow > 1) { addDetail("Multisow", `${crop.multisow} seeds per module`); } else { addDetail("Multisow", "No"); } if(crop.spacing >= 1) { addDetail("Spacing", `${crop.spacing} ${crop.multisow > 1 ? "clumps" : "plants"} per square foot`); } else { addDetail("Spacing", `1 per ${Math.floor(1 / crop.spacing)} square feet`); } addDetail("Needs a trellis", crop.trellis ? "Yes" : "No"); overlay.classList.add("overlay"); overlay.appendChild(modal); container.appendChild(overlay); overlay.addEventListener("click", event => close()); } function makeDetailLink(crop) { const span = document.createElement("span"); span.classList.add("crop"); span.appendChild(document.createTextNode(crop.name)); span.addEventListener("click", event => { viewCropDetails(crop); }); return span; } function refreshTable(crops, season, schedule) { function createWeeksRow(season) { const row = document.createElement("tr"); row.appendChild(document.createElement("th")); season.weeks.forEach(week => { const td = document.createElement("th", {scope: "col"}); const firstFrost = week.offset == 0; const lastFrost = week.offset == season.length; const dateString = monthAndDay(week.date); const label = firstFrost || lastFrost ? `${dateString}❄`: dateString; const text = document.createTextNode(label); td.appendChild(text); row.appendChild(td); }); return row; } function createHeader(season) { const thead = document.createElement("thead"); const row = document.createElement("tr"); ["Crop", "Week"].forEach(column => { const th = document.createElement("th", {scope: "col"}); const text = document.createTextNode(column); th.appendChild(text); row.appendChild(th); }); thead.appendChild(row); thead.appendChild(createWeeksRow(season)); return thead; } function createCropRow(crop, schedule) { function icon(name) { const img = document.createElement("img"); img.classList.add("icon"); img.src = `images/${name}.svg`; return img; } const row = document.createElement("tr"); const th = document.createElement("td"); th.appendChild(makeDetailLink(crop)); row.appendChild(th); schedule.forEach(week => { const td = document.createElement("td"); td.title = monthAndDay(week.date); week.actions.forEach(action => { const iconName = { start: "start-indoors", sow: "direct-sow", transplant: "transplant" }[action]; td.appendChild(icon(iconName)); }); row.appendChild(td); }); return row; } const scheduleTable = document.getElementById("schedule"); const tbody = document.createElement("tbody"); clearElement(scheduleTable); scheduleTable.appendChild(createHeader(season)); crops.forEach(crop => { tbody.appendChild(createCropRow(crop, schedule[crop.name])); }); scheduleTable.appendChild(tbody); } function refreshInstructions(crops, season, schedule) { const instructionsDiv = document.getElementById("instructions"); season.weeks.forEach(week => { const actions = crops.flatMap(crop => { const i = week.offset + WEEKS_BEFORE_LAST_FROST; return schedule[crop.name][i].actions.map(action => { return { crop: crop, type: action }; }); }).sort((a, b) => { if(a.type < b.type) { return -1; } else if(a.type > b.type) { return 1; } return 0; });; if(actions.length > 0) { const weekDiv = document.createElement("div"); const heading = document.createElement("h3"); heading.appendChild(document.createTextNode(monthAndDay(week.date))); weekDiv.appendChild(heading); actions.forEach(action => { const p = document.createElement("p"); let prefix; let suffix; if(action.type == "start") { prefix = "Start "; suffix = " indoors."; } else if(action.type == "sow") { prefix = "Sow "; suffix = " outdoors."; } else if(action.type == "transplant") { prefix = "Transplant "; suffix = " outdoors."; } p.appendChild(document.createTextNode(prefix)); p.appendChild(makeDetailLink(action.crop)); p.appendChild(document.createTextNode(suffix)); weekDiv.appendChild(p); }); instructionsDiv.appendChild(weekDiv); } }); } function refreshView() { function loadDate(name) { const dateString = window.localStorage.getItem(name); return dateString && new Date(Date.parse(dateString)); } const lastFrostDate = loadDate("lastFrostDate"); const firstFrostDate = loadDate("firstFrostDate"); if(firstFrostDate && lastFrostDate) { const season = new Season(lastFrostDate, firstFrostDate); const schedule = makeSchedule(CROPS, season); refreshTable(CROPS, season, schedule); refreshInstructions(CROPS, season, schedule); } } function getLastFrostDateInput() { return document.querySelector("input[name='last-frost-date']"); } function getFirstFrostDateInput() { return document.querySelector("input[name='first-frost-date']"); } function loadConfiguration() { const lastFrostDate = window.localStorage.getItem("lastFrostDate"); const firstFrostDate = window.localStorage.getItem("firstFrostDate"); if(lastFrostDate) { getLastFrostDateInput().value = lastFrostDate; } if(firstFrostDate) { getFirstFrostDateInput().value = firstFrostDate; } } function loadIntensiveGarden() { loadConfiguration(); getLastFrostDateInput().addEventListener("input", event => { window.localStorage.setItem("lastFrostDate", event.target.value); refreshView(); }); getFirstFrostDateInput().addEventListener("input", event => { window.localStorage.setItem("firstFrostDate", event.target.value); refreshView(); }); refreshView(); } window.addEventListener("load", loadIntensiveGarden);