using System.Globalization; using System.IO.MemoryMappedFiles; using System.Text; namespace ModuleCore.Calendar; public sealed class Calendar { public const string DefaultMarkedOffSymbol = "x"; public const DayOfWeek DefaultStartOfWeek = DayOfWeek.Monday; private readonly string _markedOffSymbol; private readonly DayOfWeek _startOfWeek; /// /// Holds configured day abbreviations, based on . /// /// Using english as the culture: Sunday = Sun, Monday = Mon and so on. This lookup also starts with /// Sunday as the first day to match the enum starting with Sunday. /// /// private readonly List _daysOfWeekLookup; /// /// Used to work out how to pad columns for each day, based on the longest abbreviated day /// private readonly int _dayWidth; private readonly CultureInfo _cultureInfo; private readonly string _rolloverDaySymbol = ""; public Calendar(string? markedDaySymbol = null, DayOfWeek? startOfWeek = null) { _markedOffSymbol = markedDaySymbol ?? DefaultMarkedOffSymbol; _startOfWeek = startOfWeek ?? DefaultStartOfWeek; // Generate localised day of week lookup table _daysOfWeekLookup = new List(7); _dayWidth = _markedOffSymbol.Length; // TODO: make this passed in from configuration, and if its invalid default it to // CultureInfo.CurrentCulture // TODO: test if its possible to determine if a culture string is invalid and what happens _cultureInfo = new CultureInfo("en-AU"); for (var i = 0; i < 7; i++) { var localisedDayAbbreviation = _cultureInfo.DateTimeFormat.GetAbbreviatedDayName((DayOfWeek)i); // For unicode languages (such as Japanese and Chinese), the length of a day will be reported as 1, // but will display in a way that makes them look short or offset. // eg: // ┌───────────────────────────┐ // │ 7月 │ // ├───┬───┬───┬───┬───┬───┬───┤ // │ 月 │ 火 │ 水 │ 木 │ 金 │ 土 │ 日 │ // ├───┴───┴───┴───┴───┴───┴───┤ // There's not really an easy fix I know of (yet) to resolve this (and I'm not really too bothered by it yet) // so unfortunately it has to stay as a known bug as is currently if (localisedDayAbbreviation.Length > _dayWidth) { _dayWidth = localisedDayAbbreviation.Length; } _daysOfWeekLookup.Add(localisedDayAbbreviation); } // Increase the width by 2 to include a space on either side _dayWidth += 2; } public string RenderCalendar(DateTime month) { var sb = new StringBuilder(); // Generate the header for the month/days of week GenerateHeader(sb, month); var calendarForMonth = GenerateCalendarForMonth(month, _markedOffSymbol, _startOfWeek, _rolloverDaySymbol); for (var i = 0; i < calendarForMonth.WeeksInMonth; i++) { sb.Append("│"); for (var j = 0; j < 7; j++) { var index = j + i * 7; // We align days to the right of their box, 1 off from the border. // Sure I could center them, but honestly this is better to read. sb.Append(calendarForMonth.Calendar[index].PadLeft(_dayWidth - 1)); sb.Append(" │"); } sb.AppendLine(); } // Cap the calendar off GenerateSpacer(sb, "└", "─", "┴", "┘", true); return sb.ToString(); } private void GenerateHeader(StringBuilder sb, DateTime month) { // Top border for the calendar, with padder and seperator the same as there's no columns immediately // under it GenerateSpacer(sb, "┌", "─", "─", "┐"); var monthString = month.ToString("MMMM yyyy", _cultureInfo); // total width of the calendar is width of the localised day calculated in the constructor, plus 7 extra // to account for separators for each day. // It shouldn't need to be said that the 7 here stands for days in a week var calendarWidth = _dayWidth * 7 + 7; // Calculate the offsets for var offsets = ( left: Math.Floor(calendarWidth / 2.0 + monthString.Length / 2.0), right: Math.Ceiling(calendarWidth / 2.0 - monthString.Length / 2.0) ); sb.Append("│") .Append(monthString.PadLeft((int)offsets.left)) .AppendLine("│".PadLeft((int)offsets.right)); // Top border for the week GenerateSpacer(sb, "├", "─", "┬", "┤"); // Work out the week display based on start of week var d = 0; do { // Index into the day of week, wrapping if needed from the configured start of the week. // The DayOfWeek enum starts with Sunday, so by default the start of the week is 0, // But most calendars visually start with Monday as the start of the week which is our default start // of week. // We also pad the string to the width of the day (minus 2 for spaces either side of the day), if // the marked day symbol is a long string. // Why did I decide to support arbitrary length marked days? Why not? sb.Append($"│ {_daysOfWeekLookup[(d + (int)_startOfWeek) % 7].PadLeft(_dayWidth - 2)} "); d++; } while (d < 7); // End the week block sb.AppendLine("│"); // bottom border GenerateSpacer(sb, "├", "─", "┼", "┤"); } private void GenerateSpacer(StringBuilder sb, string leftCap, string padder, string seperator, string rightCap, bool noNewLine = false) { sb.Append(leftCap); for (var i = 0; i < 7; i++) { for (var j = 0; j < _dayWidth; j++) { sb.Append(padder); } if (i < 6) { sb.Append(seperator); } } if (noNewLine) { sb.Append(rightCap); } else { sb.AppendLine(rightCap); } } private MonthCalendar GenerateCalendarForMonth(DateTime month, string markedOffSymbol, DayOfWeek calendarStartOfWeek, string rolloverDaySymbol) { // TODO: use startOfWeek to offset the start of the calendar later // Reset the month to the start var startOfMonth = new DateTime(month.Year, month.Month, 1); // If the visual start of the month is before the actual start day of the month, we need to visually shift the // calendar "back" a week, and pad appropriately. // Eg, if the start of the month is Sunday, but our visual start of the week is a Monday, then we need to // make a new week to display Monday as the start, and then pad blank days to Sunday. // This could probably be calculated better to avoid annoying to read logic but I'm drunk at this moment so the // brain ain't there. // For what its worth, I wasn't drunk for the rest of the code in this file, as hard as that is to believe! var daysToPadToStartMonth = startOfMonth.DayOfWeek >= calendarStartOfWeek ? startOfMonth.DayOfWeek - calendarStartOfWeek : 7 - (calendarStartOfWeek - startOfMonth.DayOfWeek); var daysInMonth = DateTime.DaysInMonth(month.Year, month.Month); // TODO: a lot of this could probably be simplified by simply taking the start day of the week, and then // based on where it would be in the first week, work out if the month is 4, 5 or 6 weeks from that. // all this needs is a pre-calculated table and that's it. // Weeks have 7 days so there's only a fixed permutation of weeks based on the day of the week a month // starts on, and months have anywhere from 28 to 31 days // Increase the days by the previous months var paddedDaysInMonth = daysInMonth + daysToPadToStartMonth; // How many days to add to the end to make the absolute number of days (including any // days that would visually roll over on a calendar) // eg, if there are 2 "roll over" days from the previous month and this month has 31 days, // Work out how many days we have left from 7, then get the _final_ number of days to make it a multiple // _of_ 7. // So this results in (33%7) == 5 - 7 == 2 + 33 == 35 % 7 == 0 which means we have a "complete" month // including next month roll over. // Visually we won't be showing those roll over days but it makes things easier if I decide to later. // If the days in the month plus padded days is already a multiple of 7, we don't need to add any more days. var daysToPadToEndMonth = paddedDaysInMonth % 7 != 0 ? 7 - paddedDaysInMonth % 7 : 0; paddedDaysInMonth += daysToPadToEndMonth; // Round to the nearest week after padding for the previous months days. // Naively allowing a loss of precision via int division because the paddedDaysInMonth should always // be a multiple of 7. // "should always" is going to be found not as true later var weeksInMonth = paddedDaysInMonth / 7; var cal = new List(); for (var i = 0; i < daysToPadToStartMonth; i++) { cal.Add(rolloverDaySymbol); } var today = new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); // If we're in the same year and month, any days already elapsed should be marked off, // otherwise we display all days. // If the DateTime we're rendering is in the past we render all days as marked off // TODO: maybe don't do that and display the calendar differently var hideElapsedDays = today.Year == month.Year && today.Month == month.Month; for (var i = 1; i < daysInMonth + 1; i++) { // if we're marking off elapsed days and we're not today or ahead, 'mark' it off if (hideElapsedDays && i < today.Day) { cal.Add(markedOffSymbol); } else { cal.Add($"{i}"); } } // pad the rollover days at the end of the calendar for (var i = 0; i < daysToPadToEndMonth; i++) { cal.Add(rolloverDaySymbol); } return new MonthCalendar { Calendar = cal, WeeksInMonth = weeksInMonth }; } } public class MonthCalendar { public List Calendar { get; set; } public int WeeksInMonth { get; set; } }