Thursday, July 20, 2023

Month and Year

For short calendars, just having the day number is enough. For longer calendars, it is also necessary to have some indication of which month (and possibly which year) is intended.

In the past, I have just put the month and date to the right of the calendar, starting as the month changes. This time, however, I am going to go for a "watermark" effect in which the month and year appear behind the calendar. This seems intuitively easy, but there are four separate aspects we need to consider:
  • Which month/year combinations we want to show;
  • Which and how many rows each should occupy;
  • Consequently how big the month name can/should be;
  • The actual content and styling of the watermark.
All of this will be spread across all the code we have so far, with each aspect being taken on in the appropriate place.

First, let's consider which month names we want to display. This is most easily handled in redraw, where we are scrolling through the weeks. As we consider each week, we consider if we consider it to be a first or last week of the month and thus build a table of rows and their respective month names.

If that sounds simple, you haven't considered the problem.

First off, what do we do with rows that have the end of one month and the start of the next month? I say we ignore them: it would be confusing to label them as either.

What do we do if we start halfway through a month? We just collect the one or two rows that are definitely in that month.

What if we start with the last few days of the month on the same row as another month? We ignore it as before.

And we need to do all of that while keeping track of what month and year we are in, along with how many months that is.

Right, let's get started.

We're going to make a start by upgrading the information we send between the functions to an object. This now has an element numRows which reflects the total number of rows, as before, and a new months array which will contain objects describing the desired month labels. We also create an (initially null) variable to track the "current" month.

We then use the leftDate to calculate the rightDate (six days later - the date in the right hand column). This enables us to easily check if the whole row contains the same month. If not, we just set the tracking variable to null.

The next case we consider is whether we are currently tracking a month and it is the same month as the left date. If so, this is just another row in that month and we can increment it.

The final case is when all of the week is in the same month, but we are not currently tracking it. In this case, we want to create a new object, start tracking it, and add it to our list of months.
  var thisMonth = null;
  do {
    var rightDate = new Date(leftDate);
    rightDate.setDate(rightDate.getDate() + 6);
    console.log(" showing week with", leftDate, "in the left column and", rightDate, "in the right column");

    // figure out if this is worthy of making a month
    if (leftDate.getMonth() != rightDate.getMonth()) {
      // we are not interested
      thisMonth = null;
    } else if (thisMonth && thisMonth.month == leftDate.getMonth()) { // if we are already recording this month, increment it
      thisMonth.numRows++;
    } else { // this week is all in this month and is a different month to what has gone before
      thisMonth = { month: leftDate.getMonth(), year: leftDate.getFullYear(), from: rowInfo.numRows, numRows: 1 };
      rowInfo.months.push(thisMonth);
    }
And that is checked in and tagged as PLANNING_CALENDAR_COLLECT_MONTHS.

That isn't quite all the work done, however. We also need to create divs in here that contain the text of the month names. We are going to style them in the background, but that doesn't alter the fact that we need to create divs containing the month name and year. And it makes sense to do this at the same time as creating the divs for the weeks themselves.

Each div that we create contains the appropriate text for the month and the class watermark to obtain the static styling for all watermarks and watermark-i for the individual positioning and measurement.
      var watermark = document.createElement("div");
      watermark.classList = 'watermark watermark-' + watermarkNo;
      watermarkNo++;
      var text = leftDate.toLocaleDateString(undefined, { month: 'long', year: 'numeric'});
      var wktext = document.createTextNode(text);
      watermark.appendChild(wktext);
      fbdiv.appendChild(watermark);
That is checked in and tagged as PLANNING_CALENDAR_WATERMARK_DIVS.

Now that we have the month names and know where we want to position them, we can go about trying to style them. The easy bit is the static styling, which we place in watermark.css. Simply put, we just need to choose a font and a color and then make it slip into the background by specifying a z-index and specifying absolute positioning (this also requires us to define the positioning of the feedback div as relative). We obviously don't want to specify the font size or position here, since that is the purpose of the indexed watermark type.
.watermark {
  font-family: sans-serif;
  font-weight: bold;
  color: lightgrey;
  position: absolute;
  z-index: -1;
}
Moving onto that, then. When we are defining the measurement stylesheet, we have the list of rows which we want to style, and separately we know how big each row is. We now need to figure out how much space there is in those rows (both horizontally and vertically) and then how we can scale it to make it fit. In order to do that, we need to know a reference size for the text we want to display. This, in turn, requires us to know what that text is. Looking back at what we did earlier, it's actually quite easy to rearrange the code to include that by putting the creation of thisMonth to the end of the else clause.

The recommended way to figure out the size of text is to use an HTML canvas object and use the TextMetrics object. Even though we don't want a canvas per se, we will thus add a hidden canvas and use that to do the measuring.
  <body onload="init()">
    <div id='controls' class="controls">
      <canvas id='canvas' style="display: none;"></canvas>
      <label for="start-date">Start date:</label>
var metricFontSize = 10;
  canvas = document.getElementById("canvas");
  sheet = new CSSStyleSheet({ media: "screen" });
  printSheet = new CSSStyleSheet({ media: "print" });
  document.adoptedStyleSheets = [sheet, printSheet];
  cx = canvas.getContext("2d");
  cx.font = metricFontSize + "px sans-serif";
It's fairly easy then to see how to measure the text using the canvas measureText function. This then gives us a width and height of the bounding box for the text at a known font size.

The available vertical space for the text is the number of rows multiplied by the row height plus the number of gaps (one less than the number of rows) multiplied by the y margin. The available horizontal space is likewise 7 (the number of columns) multiplied by the cell width plus 6 multiplied by the x margin.

Now, obviously, we don't want to use all of that space - it would look too cramped. So we'll take 75% of that and see what scale we could get away with in both horizontal and vertical dimensions, and then choose the smaller of those. It's then just maths to put away all the rest of the placement and scaling.

One final problem which crops up is the "phantom space" at the top of the div. This is just caused by CSS allowing extra space that is "never used" between lines. There doesn't seem to be an easy way to remove this (I would expect to be able to specify where I want the baseline relative to the "top" of the div, but I can't), but it isn't that far away, so I'll not worry about it.
  for (var i=0;i<rowInfo.months.length;i++) {
    handleWatermarks(sheet, i, rowInfo.months[i], xday, xmargin, yweek, ymargin);
  }
}

function handleWatermarks(sheet, idx, rowInfo, xcell, xmargin, ycell, ymargin) {
  var mx = cx.measureText(rowInfo.text);
  var width = mx.actualBoundingBoxRight + mx.actualBoundingBoxLeft;
  var height = mx.actualBoundingBoxDescent + mx.actualBoundingBoxAscent;

  var availx = 7 * xcell + 6 * xmargin * 2;
  var availy = rowInfo.numRows * ycell + (rowInfo.numRows-1) * ymargin * 2;
  
  var scalex = availx/width;
  var scaley = availy/height;
  var scale = Math.min(scalex, scaley) * .75; // .75 to leave some space around the edges

  var usedx = scale * width;
  var usedy = scale * height;
  var left = (availx - usedx) / 2;
  var top = (availy - usedy) / 2 + rowInfo.from * (ycell + ymargin * 2);

  sheet.insertRule(".watermark-" + idx + "{ font-size: " + scale*metricFontSize + "pt; left: " + left + "px; top: " + top + "px; }");
So that's the display version sorted out, but what we're really after is the printed version. And a moment's testing will indicate that the printed version does not. For a very simple reason, which is that all of this canvas calculation is done in pixels, and all our measurements are in millimetres or inches. And we are just dividing one by the other. So we need to do a conversion. Allegedly (good enough for me) there are 96px in an inch, which is the same as 3.78px in a mm. And then we need to make sure that our units are correctly specified in the stylesheet.
  for (var i=0;i<rowInfo.months.length;i++) {
    handleWatermarks(sheet, i, rowInfo.months[i], pageSize.unitIn, xday, xmargin, yweek, ymargin);
  }
}

function handleWatermarks(sheet, idx, rowInfo, unitIn, xcell, xmargin, ycell, ymargin) {
  var mx = cx.measureText(rowInfo.text);
  var width = mx.actualBoundingBoxRight + mx.actualBoundingBoxLeft;
  var height = mx.actualBoundingBoxDescent + mx.actualBoundingBoxAscent;
  switch (unitIn) {
    case "mm": {
      width /= 3.78;
      height /= 3.78;
      break;
    }
    case "in": {
      width /= 96;
      height /= 96;
      break;
    }
  }

  var availx = 7 * xcell + 6 * xmargin * 2;
  var availy = rowInfo.numRows * ycell + (rowInfo.numRows-1) * ymargin * 2;
  var left = (availx - usedx) / 2;
  var top = (availy - usedy) / 2 + rowInfo.from * (ycell + ymargin * 2);

  sheet.insertRule(".watermark-" + idx + "{ font-size: " + scale*metricFontSize + "pt; left: " + left + unitIn + "; top: " + top + unitIn + "; }");
And everything is checked in and tagged as PLANNING_CALENDAR_WATERMARKS_PRINTED.

Formatting for paper

Having decided on our approach, we need to implement it.

We will repeat our earlier approach of dividing the work into "static" and "measuring" stylesheets. So, we start by including a new stylesheet, printing.css, which we include with the media type print.

First off, we want to turn off all the irrelevant features, so we set the .controls div to have display: none and the feedback div to have no border.

To test this, it is possible to use the Chrome Developer Tools to select a "render emulation mode" of "print". Exactly how varies between versions; right now (v115) there is a kabob menu on the developer tools which has "More tools" one of which is "Rendering". In the pane that opens there is an item "Emulate CSS media type" for which one of the options is "print". By selecting that, you can see the effects of your print stylesheets directly in the browser window. You can also have the same page loaded in two separate windows, so that you can see the effects of screen and print media side by side.

So far, so good. Now onto the hard part.

We have defined all our measurements for the screen in a stylesheet. We want to keep that, so we need to add one in parallel that defines the measurements for printing on our chosen paper size (my plan had been to have one for each paper size and have the right one be chosen during printing, but this way is less work for me). So, first, we can go back and make the measuring sheet we have already created "screen only". (Unlike the base spreadsheet, there is nothing we want to reuse from this - it is all measurements that will be different between the two media). This just involves adding an object {media: "screen"} to the constructor of the CSSStyleSheet. Doing this reduces our print window to gibberish again as all the measurements have disappeared.

We next create a second sheet with the print media type, and ensure that both of them are adopted by the document.

This is all checked in as PLANNING_CALENDAR_MINIMAL_PRINT_STYLESHEET.

Now we need to do a refactoring. The method fitToPageSize is called from redraw whenever anything changes. This includes the start and end dates, the first day of the week, the size of the screen and the paper options. Some of these affect the screen, some the printed page, some both, some neither. But rather than figure that out, we are just going to relayout both screen and printed page every time. And it is basically exactly the same logic, just with different options. So my strategy is to tease this apart and to have this method call a new pageLayout method that does the laying out given the number of rows, the stylesheet to populate, and a set of options about size and the like. For now, I'm not going to do anything with the new print stylesheet, just refactor the code we have currently.

This involves removing a couple of the globals (borderX, borderY), moving the call to calculateSizeOfFeedbackDiv to the invocation of the new pageLayout method, and enhancing the result from calculateSizeOfFeedbackDiv to include the size of the border and the measurement unit (the screen will use px and the paper either mm or in).

That is then tagged as PLANNING_CALENDAR_EXTRACT_PAGELAYOUT.

This, of course, has done nothing for printing, except that now we can write one line that does handle the print case:
  pageLayout(printSheet, rows, calculatePaperSize());
This does depend on the calculatePaperSize method, however, but for now we are just going to hack that in:
function calculatePaperSize() {
  var borderX = 1, borderY = 1;
  return { x : 210, y : 297, unitIn: "mm", borderX, borderY };
}
And we can tag that as PLANNING_CALENDAR_PRINT_LAYOUT.

Of course, nothing is ever that simple, and we soon find that although this "fits" on a single piece of paper, it takes three. There is a very simple reason for this: paper has margins and we are using the whole of the piece of paper and thus overwriting the margins. I don't know how to find out what margins it is allowing than I do what sized paper it is using, but I do know how to overwrite the margins: an @page directive allows us to set the margins. We can choose any margin we want and then do our calculations based on having that much less space. (There is also an issue of duplicate margins, which can be addressed by recognizing the top and bottom rows as special, along with the left and right columns, and ensuring that the appropriate margin setting is 0 in this case; I can't be bothered right now, although I may come back to it; pull requests welcome).

OK, checked in and tagged as PLANNING_CALENDAR_PRINT_MARGINS.

So, finally for the basic calendar, we need to handle the various page sizes (and landscape) that we offered in the dropdowns. This is just some simple comparison of the currently selected values and returning the right hardcoded objects in calculatePaperSize. The landscape option is handled by taking the hardcoded object and then exchanging the X and Y values.

So that, apart from a ludicrous amount of testing, is that for the basic calendar. And is checked in as PLANNING_CALENDAR_BASIC_COMPLETE.

So what is left? Well, there are thousands of features that could be added, but I'm going to add three:
  • Annotations of the current month/year and other styling (such as weekends);
  • Ability to automatically populate with ICS calendars loaded over the web;
  • The ability to define a calendar template for easier sharing.
I may add other features over time, including the ability to split a calendar over multiple pages, but that is more likely to go straight into the live version than be described here. And, as I say, pull requests are always welcome.

Wednesday, July 19, 2023

Handling paper sizes

It's now time to turn our attention to the other really important part of this process: actually printing something. I have lived in both the UK and the US, and so I've had to deal with the whole A4/Letter thing on a personal basis. And, from time to time, I've also wanted to print calendars on A3 and tabloid paper (mainly to fit more on). So I definitely want to be able to handle this, because it's a terrible waste of paper and space if you have to assume you have paper 11" x 210mm (mismatched units intentional).

So, how do we tackle this? Well, I think the theory is that you use @media queries to handle it. Although most of the standards bodies seem more concerned in your ability to specify what page size you (as a document designer) wants than handling the page size the user has chosen to print on. (See the @page size descriptor for more details, including a (to me) humorous comparison between A4 and letter paper).

So there doesn't seem to be a nice simple
@media  print  and(paper-size:a4)  {  …  }
query, but rather we need to use something like:
@media  print  and  (min-width:  208mm)  and  (max-width:  212mm)  {  …  }
which is incredibly unclear and quite hacky. The idea here is that you can examine the width of the paper and if it is in the suitable range you can choose to use the settings for (in this case) A4.

Except, as far as I can tell, in Chrome at least, it doesn't work. I created a separate test case in pagesize.html and pagesize.css to test out these things, and I found I needed to test whether the paper size was 519mm, at which point it seemed to work the same for all different paper sizes.

Googling suggested that this is in fact a widespread problem, and thus I don't want to go near it (even though I do). But not supporting different paper sizes is not an option, so we are going to have to ask the user what their paper size is and then generate the relevant values based on that. I know for myself I will get this wrong more than once. Oh, and we also have to ask if they want calendars printed portrait or landscape (in my experience, for calendars of less than about four weeks, landscape is preferable; over four weeks generally demands a portrait layout, but it probably depends on what your application is). Sadly, this also limits the range of paper sizes we can offer (although we could offer a "custom" option; pull requests are always welcome).

In conclusion then, we are just going to create a second set of stylesheets which simply use @media print, and then use our own dropdown to determine the appropriate values. All of this work is tagged PLANNING_CALENDAR_PAPER_SIZES.
      <label for="page-size">Page Size:</label>
      <select id="page-size" onchange="redraw()">
        <option value="a4">A4</option>
        <option value="letter">Letter</option>
        <option value="a3">A3</option>
        <option value="tabloid">Tabloid</option>
      </select>
      <input type="checkbox" id="landscape" onchange="redraw()">
      <label for="landscape">Landscape</label>

CSS from JavaScript

Right, now we come to the meat of this whole project. In the basic CSS presented in the last episode, I opted for fixed cell measurements and completely flexible page measurements. This is exactly the opposite of what we actually want: the paper size for the calendar is fixed (even if we offer multiple options) and we want the cell size to vary to fill the available space.

Rather than deal with the complexities of the printer right up front (I hope I don't regret this decision…), I'm going to spend this episode looking at doing that on the screen in the available space (where it is easy to debug everything) and then concern myself with the complexities of printing next time.

Before we go any further, yes, I am aware of calc in CSS, and use it frequently. However, I have convinced myself that what I want to do here goes beyond calc because of the multiple constraints that apply to any given element. If you can figure out how to do all of this just with calc, do let me know.

Contrariwise, I believe it is possible to do everything I want to do here with JavaScript and hacking at elements in the DOM. But that is painful and error-prone and, quite frankly, not as exciting as what I'm going to try and do here, which is to create a new stylesheet at run-time whose values are calculated dynamically. I'm then going to split the styling between true "styling" (which will stay in the static stylesheets) and the measuring (which will be calculated dynamically).

I've chosen to put this code in a separate JS file, mainly for clarity, but not to do all the work that I should do to make everything modular (also for clarity). So I've defined a separate initStyling method, and called that from the init method in controls.js, passing through the fbdiv which I'm then storing as styledDiv. The more important thing, though, is that I create a new CSSStyleSheet and assign it to the list of adoptedStyleSheets in the document. Note that this only "works" if you are in complete control of the set of sheets to be adopted; if you're not, you will need to do the work to merge your sheets with everyone else's.
    <link rel="stylesheet" href="css/controls.css">
    <link rel="stylesheet" href="css/feedback.css">
    <script src="js/controls.js"></script>
    <script src="js/styling.js"></script>
  </head>
  <body onload="init()">
    <div class="controls">
  start.valueAsDate = new Date();
  end.valueAsDate = new Date();

  initStyling(fbdiv);

  redraw();
var styledDiv;
var sheet;

function initStyling(fbdiv) {
  styledDiv = fbdiv;
  sheet = new CSSStyleSheet();
  document.adoptedStyleSheets = [sheet];
}
Setting the rules depends on figuring out the size of the area to be filled and then updating the rules. This is information which should be available in redraw, so we'll call the function to set the dimension rules from there. At the end of redraw, I've added a call to fitToPageSize, which is a method to be defined in styling.js.
  fitToPageSize();
}
function fitToPageSize() {
  while (sheet.cssRules.length > 0)
    sheet.deleteRule(0);
  sheet.insertRule(".body-day { width: 25mm; height: 25mm; margin: 2mm }");
  sheet.insertRule(".body-day-date { top: 3mm; left: 3mm; font-size: 5mm }");
}
Before we go any further, I'm going to remove the sizing properties from the static CSS, so that there is no conflict and it is clear whether the dynamic stylesheet is working. And my first pass in getting the dynamic sheet working is just to try and add the same (fixed) properties to it. If nothing changes, we have a working dynamic spreadsheet.

And thus we have what we wanted: a dynamic stylesheet that can be continually updated. We are just left with the task of calculating the correct values.

This is tagged PLANNING_CALENDAR_DYNAMIC_STYLESHEET.

This next bit is something I never like doing. I think the root of the problem is just that it completely violates everything that is innate to HTML which is that you have an infinite scroll of paper, and thus there is no such notion as the "height" of the page. So you have to take the window size, and then go around subtracting all sorts of random measurements (body margin, other divs, etc) until it seems to fit. And then you try it on a mobile device only to find …

So bear with me if this doesn't "quite" work for you, or if the numbers (such as 16, double the "default" body margin of 8) seem to have been plucked out of nowhere. Imagine, if you will, that this code is in fact just going to return the available size for the feedback div. To help you, I will bury it in a function with that name and put all the nastiness in that, and then delegate the layout work to another function which takes the result.

Now, having done all that, I have a size in pixels. Up until now, I've been using mm, but there's no real difference between them for what follows, as long as you are consistent about it.

Let's tackle the horizontal first. We know that we have exactly 7 days in a week, and we also have 8 "spaces" (one on each end and six in between). Because this is CSS, each of the spaces consists of one or two margins, so actually we have 7 left margins and 7 right margins. So, if we say these will be equal, we can consider that we have 14 margins. If we say that we want a ratio of 12:1 for the days to the spaces, then we have 84 "units" in days and 7 units in spaces, for a total of 91 units. Each unit is then the width divided by 91. And then I'll round down to make sure it fits. We then need to set the width of a "day" to 12 units and the margin to be 0.5 units.
var styledDiv;
var sheet;
var controlPane;

function initStyling(fbdiv) {
  styledDiv = fbdiv;
  controlPane = document.getElementById('controls');
  sheet = new CSSStyleSheet();
  document.adoptedStyleSheets = [sheet];
}

function fitToPageSize() {
  // delte the old rules
  while (sheet.cssRules.length > 0)
    sheet.deleteRule(0);

  // calculate the page size
  var pageSize = calculateSizeOfFeedbackDiv();
  var unitIn = "px";

  // calculate desired box sizes
  var xunit = Math.floor(pageSize.x / 91);
  var xday = xunit*12;
  var xmargin = xunit/2;



  // generate new rules
  sheet.insertRule(".feedback { border-width: 0.1mm; width: " + pageSize.x + "px; height: " + pageSize.y + "px; }");
  sheet.insertRule(".body-day { border-width: 0.1mm; width: " + xday + unitIn + "; height: 25mm; margin: " + xmargin + unitIn + " }");
  sheet.insertRule(".body-day-date { top: 3mm; left: 3mm; font-size: 5mm }");
}

function calculateSizeOfFeedbackDiv() {
  var viewx = window.innerWidth;
  var viewy = window.innerHeight;

  var fbx = viewx - 16 - 5; // 16 for double body margin // allow, say, 5 for border
  var fby = viewy - controlPane.clientHeight - 16 - 5;

  return { x : fbx, y : fby };
}
When I do this, I find that the line "wraps": it's longer than I have calculated. Inspecting the DOM in Chrome, I discover there is extra space between the elements. Ah, yes, I remember adding that now when we were all text based. So I removed it from the DOM.

This is tagged PLANNING_CALENDAR_X_CORRECT.

Now, for the vertical dimension, we need to figure out how many rows there are. In fact, we have already done this when drawing the rows, which is where this function is called from, so we'll get it to count them for us and hand the number over.
  if (leftDate > from) {
    leftDate.setDate(leftDate.getDate() - 7);
  }
  var numRows = 0;
  do {
    console.log(" showing week with", leftDate, "in the left column");


    // advance to next week
    leftDate.setDate(leftDate.getDate() + 7);
    numRows++;
  } while (leftDate <= to);

  fitToPageSize(numRows);
}
Then the calculation is basically the same: we have a given number of rows, with a margin above and below each one, and we want the ratio to be about 12:1. So we divide the page size by the number of rows multiplied by 13, and use that as the y unit size. Oh, and don't forget that CSS measurements for things like margins use the "compass points" notation so that y comes before x.

Finally, we want to calculate the position and size of the date number. This wants to be about a fifth of the way into the box and about an eighth of its size (a completely arbitrary and aesthetic judgment on my part, you understand). More importantly, the size wants to be the smaller of what x and y will allow (the positions are independent). However, there are a couple more constraints: it would be good if the number were legible, so I would like it to always be at least 8px, but it can't be more than the available space in the box, which implies a font size of about three quarters the height of the box.
function fitToPageSize(rows) {
  var xpos = xday / 5;
  var xsize = xday / 8;

  var yunit = Math.floor(pageSize.y / (rows * 13));
  var yweek = yunit*12;
  var ymargin = yunit/2;
  var ypos = yweek / 5;
  var ysize = Math.min(Math.max(yweek / 8, 8), yweek * 3/4);
  
  var dateSize = Math.min(xsize, ysize);

  // generate new rules
  sheet.insertRule(".feedback { border-width: 0.1mm; width: " + pageSize.x + "px; height: " + pageSize.y + "px; }");
  sheet.insertRule(".body-day { border-width: 0.1mm; width: " + xday + unitIn + "; height: " + yweek + unitIn + "; margin: " + ymargin + unitIn + " " + xmargin + unitIn + " }");
  sheet.insertRule(".body-day-date { top: " + ypos + unitIn + "; left: " + xpos + unitIn + "; font-size: " + dateSize + unitIn + " }");
}
So far, so good. Checked in and tagged as PLANNING_CALENDAR_XY_CORRECT.

Now, what's bugging me is that the rounding that I did to make sure that the calendar doesn't overflow is working perfectly in the horizontal direction, but is slightly too aggressive in the vertical dimension. Simply removing it causes the box to overflow, as I'd expect, as does rounding to a single decimal place. I hate figuring these things out, but I guess I have to.

The problem, "of course", is that I have been ignoring the effect of the border widths in these calculations and just thinking that it will cancel out. Which it kind of does, but it's too ugly.

So the only solution is actually to take that into account, calculate the correct offsets given the border width, and remove the Math.floor. Then it will all work correctly.

This is too dull and testy to show here, but you can see it in the repository; it's checked in and tagged as PLANNING_CALENDAR_BORDER_INCLUDED.

One last thing: we also want to redraw the calendar when the window size changes (e.g. going full screen or removing the developer tools). To handle this, we need to add a handler for the resize event. This is tagged PLANNING_CALENDAR_HANDLE_RESIZE.

  initStyling(fbdiv);

  addEventListener("resize", redraw);
  redraw();
}

Tuesday, July 18, 2023

Basic CSS

How much styling we want to add is always a difficult question. For me, it tends to be somewhat "creeping" - I'll start with "just enough" and then keep adding a bit at a time while I'm working on other features and using the code. On here, though, I tend to keep it minimal, especially since I'm trying to make it clear what is important and what isn't. So while I might usually do things such as styling the date pickers, all I'm going to do here is make it clear what's included in the "feedback" box and then style it to look like a calendar.

So, in this case, I add a border around the feedback area to make it clear what the "page" looks like, then a border around each "date" box, and then style the number in the date box. The only oddity (IMHO) is styling body-day as position: relative which has nothing to do with what it wants, but just to make it the frame of reference for the position: absolute in body-day-date.

Simples. You may also notice that I added a css class onto the feedback box, changed the id to a class for the controls box, and fixed a bug in where the date value was being added in controls.js.
.feedback {
  border: 0.1mm solid black;
}

.body-day {
  display: inline-block;
  border: 0.1mm solid black;
  margin: 2mm;
  width: 25mm;
  height: 25mm;
  position: relative;
}

.body-day-date {
  position: absolute;
  top: 3mm;
  left: 3mm;
  font-family: 'Courier New', Courier, monospace;
  font-weight: bold;
  font-size: 5mm;
}
This code is in PLANNING_CALENDAR_BASIC_CSS.

Wiring up the Basics

Now that we have the outline of the calendar, we want to actually generate the calendar that has been asked for. As you may have guessed, 90% of this blog is going to be about how to get the calendar to look the way you want; the actual generation is a few simple lines of JavaScript. Certainly I'm doing this more than anything else (well, I guess I'm actually interested in the end product, but as I already have a functioning desktop app…) to investigate building CSS stylesheets dynamically in JavaScript.

That being the case, I'm going to gloss over most of this with a couple of classic handwaves. As always, if you feel I've left out significant details, get back to me and I'll elaborate.

The outline has three ui elements, each of which controls what is seen in the feedback window, and when any of them change, we want to generate an appropriate calendar based on the current set of three values. So, we add a listener on the appropriate change event for each control, all of which invoke the same method to redraw the calendar.

While I'm about setting that up, I have also added an init method which initializes the default start and end dates to be today and, for good measure, then generates a "default" feedback pane to be this week, starting on Monday. It's not a lot, but it gets you started.
    <script src="js/controls.js"></script>
  </head>
  <body onload="init()">
    <div id="controls">
      <label for="start-date">Start date:</label>
      <input type="date" id="start-date" onchange="redraw()">
      <label for="end-date">End date:</label>
      <input type="date" id="end-date" onchange="redraw()">
      <label for="first-day">First Day:</label>
      <select id="first-day" onchange="redraw()">
        <option value="1">Monday</option>
        <option value="2">Tuesday</option>
        <option value="3">Wednesday</option>
        <option value="4">Thursday</option>
        <option value="5">Friday</option>
        <option value="6">Saturday</option>
        <option value="0">Sunday</option>
      </select>
    </div>
What we want to draw is basically the same as what I put in the outline: a nested structure consisting of the whole calendar containing weeks, containing days, containing information about that day. redraw needs to clear out the existing feedback calendar and draw a new one starting the week of the start date from the first day until the end date is in the past.

A couple of notes on this code which is, otherwise, fairly straightforward:
  • The calculation of the offset for the start of the week works most of the time, but when the start date is a day of the week before the "left column day", the first week might be missed, and it is important to check for this and start the calendar a week earlier.
  • It's important to copy the value of from into leftDate (and leftDate into cellDate), otherwise, because dates in JavaScript are references, both leftDate and from will be updated, making this check pointless.
  • In order to make this work correctly, I changed the values of the options in the select statement from what I had initially used.
  • I have used a "do" loop to make sure at least one week comes out, but after that it is just a question of deciding whether the end date is in the future or the past. This automatically handles the case where the end date is before the start date in a satisfactory way.
  • There are comments logged to the console to clarify what is happening.
Obviously, we have not provided any styling yet. Because of this, by default, all the dates "run together" on a line to make a mess. This did not happen in the outline because of the white space inherent in the layout of the document. To replicate this, I have added a white space character between successive date boxes which will be removed when we add the borders.
var start, end, first, fbdiv;

function init() {
  start = document.getElementById('start-date');
  end = document.getElementById('end-date');
  first = document.getElementById('first-day');
  fbdiv = document.getElementById('feedback');

  start.valueAsDate = new Date();
  end.valueAsDate = new Date();

  redraw();
}

function redraw() {
  var from = new Date(start.value);
  var to = new Date(end.value);
  var leftColumn = parseInt(first.value);
  console.log("Generating calendar from", from, "to", to, "based on", leftColumn);

  fbdiv.innerHTML = '';
  var leftDate = new Date(from);
  leftDate.setDate(leftDate.getDate() - leftDate.getDay() + leftColumn);
  if (leftDate > from) {
    leftDate.setDate(leftDate.getDate() - 7);
  }
  do {
    console.log(" showing week with", leftDate, "in the left column");

    // create a div for the whole week
    var week = document.createElement("div");
    week.className = "body-week";
    fbdiv.appendChild(week);
    for (var i=0;i<7;i++) {
      var cellDate = new Date(leftDate);
      cellDate.setDate(cellDate.getDate() + i);
      console.log(" cell", i, "has date", cellDate);

      // create a div for each day, to contain all the aspects we will have
      var day = document.createElement("div");
      day.className = "body-day";
      week.appendChild(day);

      // the first aspect is the date
      var date = document.createElement("div");
      date.className = "body-day-date";
      day.appendChild(date);

      // and set the date text in here
      var dateValue = document.createTextNode(cellDate.getDate());
      day.appendChild(dateValue);

      // add a space between days until we style this
      var space = document.createTextNode(" ");
      week.appendChild(space);
    }

    // advance to next week
    leftDate.setDate(leftDate.getDate() + 7);
  } while (leftDate <= to);
}
The code is under the tag PLANNING_CALENDAR_BASIC_WIRING.

Monday, July 17, 2023

Planning Calendars

I am, by nature, a planner.

Many who meet me through the agile community are surprised by this, but, in fact, the nature of my planning fits well with agile: it is small scale, and can be repeated endlessly as new information emerges.

As such, I am always looking for tools to help me achieve this or communicate this. And because the things I do are generally small scale and "cheap", the tools I am looking for are generally not high priced enterprise things.

One such tool is a calendar. As I always like to say when planning projects, "you can always get your money back; but once the time is gone, it's gone". In my experience, most people don't have a good grasp of time in general, and, in particular, that only one thing fits in a given spot.

The origins of this tool go back to a "Filofax replacement" project I was building on NeWS (if you're under 50, Google is your friend here) back in the late 80s. Not much came of that, but the PostScript header file is the basis of the subsequent calendar programs I've written over the past 30 years. The plan here is to replace all of those with one that doesn't use that PostScript header file because it is 100% web based (HTML and CSS with the appropriate print media definitions).

The key to this tool is threefold:
  • Most projects I have worked on do not run on a nice, comfortable "exactly one calendar month" basis for which a standard calendar is a good fit;
  • Most people I know work on a Monday-Friday schedule, and then enjoy a weekend, whereas most calendars I can buy seem to think the week runs from Sunday to Saturday;
  • The month breaks on standard calendars are extremely distracting and often make people count the same week twice.
So, what I want is a calendar whose first week is the first week of the project, whose last week is the last week of the project and exactly fills one sheet of A4 (or "letter paper" if you happen to live in the dark ages). Sounds simple? OK, well, in that case, for good measure, I'd like to be able to choose which day of the week it starts on (although the default should be Monday) and whether you arrange it in landscape or portrait orientation (obviously, this is just on the print menu, but I want the calendar to re-layout when I do). And obviously, I want a lot of controls and visual feedback on the screen, but just the calendar on the output.

Oh, and over the past 30 years I have acquired a few new requirements that would be good to include:
  • The option to display the calendar in different "styles", in particular, how and where the month/year names are displayed, colours and shading for dates, weeks and months, and so on;
  • Sometimes I do want "long range plans": not plans as such, but if I have certain fixed engagements over the next few months, I'd like to have a calendar that has those on it so that I can see what chunks of work I might be able to commit to in that span. Experience has taught me that 13 weeks (i.e. a quarter of a year) on a calendar works, but that 26 (half a year) does not. So I'd like to be able to have multiple pages with (approximately) the same number of weeks on each.
  • There are a lot of calendars out there in ICS form that I'd like to include so that I can have them show up in order to avoid conflicts and to know when holidays are; and I'd like to be able to configure how they show up.
All good? Let's get started.

The basics

I believe in keeping things simple. So what I basically want is a single page that has a control area and a feedback area. The control area allows you to select a start date (which will be in the first row of the calendar) and an end date (which should be later than the start date, in which case it will be in the end row; otherwise you will just get a one-week calendar with the start date) and which day of the week is going to appear in the left-hand column.

The feedback area shows you approximately how the calendar will display, but without laying it out "on paper", or applying any fancy styling.

And then the "print" button does all the hard work through the application of a print media stylesheet.

Let's get started. We won't get everything we want done today, but let's at least get that outline in place.

The code for this can be found tagged PLANNING_CALENDAR_OUTLINE in the directory calendar. We're just getting started here, so nothing really works as such, but hopefully it's a useful guide to you as to where we're going with this - I know it is to me.

<html>
  <head>
    <title>Planning Calendar</title>
    <link rel="stylesheet" href="css/controls.css">
    <link rel="stylesheet" href="css/feedback.css">
  </head>
  <body>
    <div id="controls">
      <label for="start-date">Start date:</label>
      <input type="date" id="start-date">
      <label for="end-date">End date:</label>
      <input type="date" id="end-date">
      <label for="first-day">First Day:</label>
      <select id="first-day">
        <option value="0">Monday</option>
        <option value="1">Tuesday</option>
        <option value="2">Wednesday</option>
        <option value="3">Thursday</option>
        <option value="4">Friday</option>
        <option value="5">Saturday</option>
        <option value="6">Sunday</option>
      </select>
    </div>
    <div id="feedback">
      <div class="body-week">
        <div class="body-day">
          <div class="body-day-date">17</div>
        </div>
        <div class="body-day">
          <div class="body-day-date">18</div>
        </div>
        <div class="body-day">
          <div class="body-day-date">19</div>
        </div>
        <div class="body-day">
          <div class="body-day-date">20</div>
        </div>
        <div class="body-day">
          <div class="body-day-date">21</div>
        </div>
        <div class="body-day">
          <div class="body-day-date">22</div>
        </div>
        <div class="body-day">
          <div class="body-day-date">23</div>
        </div>
      </div>
    </div>
  </body>
</html>
At the moment, this website is 100% static, so you can just open index.html in your browser, and everything will just work fine. This is going to be true for a while, until we start opening ICS calendars using AJAX. Then we'll need to have it running on/in a server. So that I'm prepared, I just run this command in the web directory:
python  -m  SimpleHTTPServer  8080
And then visit localhost:8080 in my browser. I know there are 2,000 ways of doing this, so feel free to use your own. I have just got into this habit, and it keeps working for me.