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 + 3).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, {depth, fallCrop, family, headStart, interval, maturity, multisow, overwinterPlant, pause, references, spacing, springPlant, resume, species, transplant, trellis}) { // Seed planting depth in inches. this.depth = depth || .25; // Plant again in the fall? this.fallCrop = fallCrop; // Broad category (brassica, cucurbit, nightshade, etc.) this.family = family; // 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; // Overwintered crops are fall crops but they are not subject to // the same rules as plants that are harvested the same year. // Instead of planting them relative to their time to maturity, // they are planted relative to the first frost date so they can // be harvested in the spring/summer of the following year. this.overwinterPlant = overwinterPlant; // When to pause succession planting (weeks after last frost) this.pause = pause || 99; // External links to helpful information about the crop. this.references = references || []; // 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.) Null if the crop should not be planted in the // spring. this.springPlant = springPlant; // Transplanted or direct sown? this.transplant = transplant || false; // Needs a trellis? this.trellis = trellis || false; } } const CROPS = [ new Crop("Amaranth", { depth: 0.125, family: "Amaranth", maturity: 5, spacing: 4, springPlant: 0, species: ["amaranth tricolor"] }), new Crop("Arugula", { family: "Brassica", fallCrop: true, headStart: 4, maturity: 6, multisow: 3, pause: 3, resume: 11, spacing: 4, species: ["eruca sativa"], springPlant: -4, transplant: true }), new Crop("Basil", { family: "Mint", headStart: 8, maturity: 6, species: ["ocimum basilicum"], springPlant: 2, transplant: true }), new Crop("Bean (Bush)", { depth: 1, family: "Legume", headStart: 3, interval: 2, maturity: 8, spacing: 9, species: ["phaseolus vulgaris"], springPlant: 1, transplant: true, transplantIterations: 1 }), new Crop("Bean (Pole)", { depth: 1, family: "Legume", headStart: 2, maturity: 9, spacing: 8, species: ["phaseolus vulgaris"], springPlant: 1, transplant: true, trellis: true }), new Crop("Beet", { depth: .5, family: "Amaranth", headStart: 4, interval: 3, maturity: 8, multisow: 3, pause: 1, resume: 12, spacing: 4, species: ["beta vulgaris"], springPlant: -3, transplant: true }), new Crop("Bok Choy", { fallCrop: true, family: "Brassica", headStart: 3, maturity: 7, spacing: 4, species: ["brassica rapa"], springPlant: -2, transplant: true }), new Crop("Broccoli", { fallCrop: true, family: "Brassica", headStart: 4, maturity: 12, species: ["brassica oleracea"], springPlant: -3, transplant: true }), new Crop("Brussels Sprouts", { fallCrop: true, family: "Brassica", headStart: 6, maturity: 16, species: ["brassica oleracea"], transplant: true }), new Crop("Cabbage (Early)", { family: "Brassica", headStart: 4, maturity: 6, species: ["brassica oleracea"], springPlant: -3, transplant: true }), new Crop("Cabbage (Late)", { family: "Brassica", fallCrop: true, headStart: 4, maturity: 14, species: ["brassica oleracea"], springPlant: -3, transplant: true }), new Crop("Cabbage (Asian)", { family: "Brassica", fallCrop: true, headStart: 3, maturity: 10, species: ["brassica rapa"], springPlant: -3, transplant: true }), new Crop("Cauliflower", { family: "Brassica", fallCrop: true, headStart: 4, maturity: 10, species: ["brassica oleracea"], springPlant: -3, transplant: true }), new Crop("Carrot", { family: "Umbellifer", interval: 2, maturity: 10, spacing: 16, species: ["daucus carota"], springPlant: -4 }), new Crop("Cilantro", { family: "Umbellifer", interval: 2, maturity: 7, species: ["coriandrum sativum"], springPlant: 0 }), new Crop("Claytonia", { fallCrop: true, family: "Montiaceae", maturity: 6, spacing: 9, species: ["montia perfoliata"], springPlant: -5 }), new Crop("Cucumber", { depth: .5, family: "Cucurbit", headStart: 4, maturity: 9, spacing: 2, species: ["cucumis sativus"], springPlant: 1, transplant: true, trellis: true }), new Crop("Dill", { family: "Umbellifer", fallCrop: true, headStart: 3, maturity: 8, spacing: 4, species: ["anethum graveolens"], springPlant: -2, transplant: true }), new Crop("Eggplant", { family: "Nightshade", headStart: 8, maturity: 11, species: ["solanum melongena"], springPlant: 3, transplant: true }), new Crop("Endive", { depth: .125, family: "Aster", headStart: 4, maturity: 4, species: ["cichorium endivia"], springPlant: -3, transplant: true }), new Crop("Fennel", { fallCrop: true, family: "Umbellifer", headStart: 4, maturity: 10, spacing: 4, species: ["foeniculum vulgare"], springPlant: 0, transplant: true }), new Crop("Garlic", { depth: 3, family: "Allium", fallCrop: true, overwinterPlant: 2, maturity: 28, spacing: 4, species: ["allium sativum"] }), new Crop("Kale", { fallCrop: true, family: "Brassica", headStart: 4, maturity: 4, species: ["brassica napus", "brassica oleracea"], springPlant: -4, transplant: true }), new Crop("Leek (Summer)", { depth: 0.5, family: "Allium", headStart: 6, maturity: 10, multisow: 4, spacing: 1, species: ["allium ampeloprasum"], springPlant: -2, transplant: true }), new Crop("Lettuce (Leaf)", { depth: .125, family: "Aster", headStart: 4, interval: 2, maturity: 7, pause: 3, resume: 11, spacing: 4, species: ["latuca sativa"], springPlant: -4, transplant: true }), new Crop("Mache (Corn Salad)", { family: "Honeysuckle", interval: 2, maturity: 7, pause: 1, resume: 11, spacing: 16, species: ["valerianella locusta"], springPlant: -4 }), new Crop("Mustard (Asian Greens)", { family: "Brassica", headStart: 3, interval: 2, maturity: 6, pause: 3, resume: 10, spacing: 4, species: ["brassica juncea", "brassica rapa"], springPlant: -4, transplant: true }), new Crop("Onion", { depth: 0.5, family: "Allium", headStart: 6, maturity: 14, multisow: 4, references: [ { title: "Charles Dowding - Grow onions and spring onions from seed", url: "https://www.youtube.com/watch?v=1k0f4GoC6Zw" } ], spacing: 1, species: ["allium cepa"], springPlant: -4, transplant: true }), new Crop("Parsley", { family: "Umbellifer", headStart: 6, maturity: 7, spacing: 4, species: ["petroselinum crispum"], springPlant: -4, transplant: true }), new Crop("Parsnip", { depth: .5, family: "Umbellifer", maturity: 14, spacing: 4, species: ["pastinaca sativa"], springPlant: -2 }), new Crop("Pea", { fallCrop: 'sow', family: "Legume", headStart: 3, maturity: 12, multisow: 4, spacing: 4, species: ["pisum sativum"], springPlant: -4, transplant: true, trellis: true }), new Crop("Pepper", { family: "Nightshade", headStart: 8, maturity: 10, species: ["capsicum annuum"], springPlant: 3, transplant: true }), new Crop("Potato", { depth: 3, family: "Nightshade", maturity: 12, spacing: 4, species: ["solanum tuberosum"], springPlant: -3 }), new Crop("Radish (Spring)", { depth: .5, family: "Brassica", headStart: 3, interval: 3, maturity: 4, multisow: 5, pause: 4, resume: 10, spacing: 4, species: ["raphanus raphanistrum"], springPlant: -4, transplant: true }), new Crop("Radish (Winter)", { depth: .5, family: "Brassica", fallCrop: true, headStart: 3, maturity: 9, spacing: 4, species: ["raphanus raphanistrum"], transplant: true }), new Crop("Shallot", { family: "Allium", headStart: 6, maturity: 9, multisow: 3, spacing: 4, species: ["allium cepa"], springPlant: -4, transplant: true }), new Crop("Spinach", { depth: .5, family: "Amaranth", fallCrop: true, headStart: 3, maturity: 6, multisow: 3, pause: -1, resume: 11, spacing: 4, species: ["spinacia olerace"], springPlant: -5, transplant: true }), new Crop("Squash (Summer)", { depth: 1, family: "Cucurbit", headStart: 4, maturity: 8, spacing: 1.0 / 9, species: ["cucurbita pepo"], springPlant: 0, transplant: true }), new Crop("Squash (Winter)", { depth: 1, family: "Cucurbit", headStart: 4, maturity: 12, spacing: 0.5, species: ["cucurbita maxima", "cucurbita moschata", "cucurbita pepo"], springPlant: 1, transplant: true }), new Crop("Swiss Chard", { depth: .5, family: "Amaranth", headStart: 4, maturity: 4, spacing: 4, species: ["beta vulgaris"], springPlant: -3, transplant: true }), new Crop("Tomato (Indeterminate)", { family: "Nightshade", headStart: 7, maturity: 11, species: ["lycopersicon esculentum"], springPlant: 3, transplant: true, trellis: true }), new Crop("Turnip", { family: "Brassica", fallCrop: true, headStart: 3, maturity: 5, multisow: 5, pause: 6, resume: 10, spacing: 4, species: ["brassica rapa"], springPlant: -3, transplant: true }), new Crop("Alyssum", { family: "Brassica", headStart: 4, maturity: 8, species: ["lobularia maritima"], springPlant: 0, transplant: true }), new Crop("Amaranth (Flower)", { family: "Amaranth", headStart: 4, maturity: 12, species: ["amaranthus caudatus"], springPlant: 0, transplant: true }), new Crop("Borage", { family: "Borage", maturity: 7, species: ["borago officinalis"], springPlant: 0 }), new Crop("Calendula", { family: "Aster", headStart: 6, maturity: 11, species: ["calendula officinalis"], springPlant: 0, transplant: true }), new Crop("Cornflower", { family: "Aster", headStart: 4, maturity: 12, spacing: 4, species: ["centaurea cyanus"], springPlant: 0, transplant: true }), new Crop("Cosmos", { family: "Aster", headStart: 4, maturity: 12, species: ["cosmos bipinnatus"], springPlant: 0, transplant: true }), new Crop("Larkspur", { family: "Buttercup", headStart: 4, maturity: 11, spacing: 3, species: ["consolida ajacis"], springPlant: 0, transplant: true }), new Crop("Marigold", { family: "Aster", headStart: 4, maturity: 11, species: ["Tagetes erecta", "tagetes patula"], springPlant: 0, transplant: true }), new Crop("Mexican Sunflower", { family: "Aster", headStart: 6, maturity: 12, species: ["tithonia rotundifolia"], springPlant: 0, transplant: true }), new Crop("Nasturtium", { depth: 0.5, family: "Tropaeolum", headStart: 3, maturity: 8, species: ["tropaeolum majus"], springPlant: 0, transplant: true }), new Crop("Nigella", { family: "Buttercup", headStart: 4, maturity: 10, species: ["nigella hispanica"], springPlant: 0, transplant: true }), new Crop("Strawflower", { family: "Aster", headStart: 8, maturity: 10, species: ["xerochrysum bracteatum"], springPlant: 0, transplant: true }), new Crop("Sunflower", { family: "Helianthus", headStart: 2, maturity: 11, species: ["helianthus annuus"], springPlant: 0, transplant: true }), new Crop("Sweet Pea", { depth: 0.5, family: "Legume", headStart: 8, maturity: 10, species: ["lathyrus odoratus"], springPlant: 0, transplant: true }), new Crop("Zinnia", { family: "Aster", headStart: 4, maturity: 7, species: ["zinnia elegans"], springPlant: 0, transplant: true }) ]; function makeCropSchedule(crop, season) { function isSuccession(offset) { const start = offset <= crop.pause ? crop.springPlant : crop.resume; return crop.interval > 0 && offset != crop.springPlant && offset >= crop.springPlant && offset <= fallPlant && (offset <= crop.pause || offset >= resume) && (offset - start) % crop.interval == 0; } function isSuccessionStart(offset) { offset += crop.headStart; const start = offset <= crop.pause ? crop.springPlant : crop.resume; return crop.interval > 0 && crop.transplant && offset != crop.springPlant && 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 => { const actions = []; if(crop.transplant && week.offset == springStart) { actions.push("start"); } else if(crop.transplant && week.offset == crop.springPlant) { actions.push("transplant"); } else if(week.offset == crop.springPlant) { actions.push("sow"); } if(isSuccession(week.offset)) { actions.push(crop.transplant ? "transplant" : "sow"); } if(isSuccessionStart(week.offset)) { actions.push("start"); } if(crop.fallCrop) { if(crop.overwinterPlant) { if(week.offset == season.length + crop.overwinterPlant) { actions.push("sow"); } } else if(crop.transplant && week.offset == fallStart) { actions.push("start"); } else if(crop.transplant && week.offset == fallPlant) { actions.push("transplant"); } else if(week.offset == fallPlant) { actions.push("sow"); } } return { date: weeksLater(season.lastFrostDate, week.offset), actions: actions }; }); } 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); } function addReferences() { if(crop.references.length > 0) { const div = document.createElement("div"); const title = document.createElement("span"); const list = document.createElement("ul"); title.appendChild(document.createTextNode("References")); div.appendChild(title); crop.references.forEach(ref => { const li = document.createElement("li"); const link = document.createElement("a"); link.appendChild(document.createTextNode(ref.title)); link.href = ref.url; link.target = "_blank"; // open in new tab. li.appendChild(link); list.appendChild(li); }); div.appendChild(list); modal.appendChild(div); } } const container = document.getElementById("container"); const overlay = document.createElement("div"); const modal = document.createElement("div"); const heading = document.createElement("h2"); const subheading = document.createElement("span"); const speciesDetail = `(${crop.species.join(", ")}) - ${crop.family} family`; heading.appendChild(document.createTextNode(crop.name)); subheading.appendChild(document.createTextNode(speciesDetail)); subheading.classList.add("detail-subheading"); heading.appendChild(subheading); modal.classList.add("modal"); modal.appendChild(heading); 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} plants per hole (after thinning)`); } 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("Seed planting depth", `${crop.depth}"`); addDetail("Needs a trellis", crop.trellis ? "Yes" : "No"); addReferences(); 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 icon = document.createElement("span"); icon.classList.add("icon"); icon.classList.add(`icon-${name}`); return icon; } 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(); // Open/close sections of page. document.querySelectorAll("section").forEach(section => { const container = section.querySelector(".section-container"); const header = section.querySelector("h2"); header.classList.add("open"); header.addEventListener("click", event => { header.classList.remove(container.hidden ? "closed" : "open"); container.hidden = !container.hidden; header.classList.add(container.hidden ? "closed" : "open"); }); }); } window.addEventListener("load", loadIntensiveGarden);