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; }
}