259 lines
No EOL
9.6 KiB
C#
259 lines
No EOL
9.6 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Holds configured day abbreviations, based on <see cref="CultureInfo"/>.
|
|
/// <para>
|
|
/// 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 <see cref="DayOfWeek"/> enum starting with Sunday.
|
|
/// </para>
|
|
/// </summary>
|
|
private readonly List<string> _daysOfWeekLookup;
|
|
|
|
/// <summary>
|
|
/// Used to work out how to pad columns for each day, based on the longest abbreviated day
|
|
/// </summary>
|
|
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<string>(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<string>();
|
|
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<string> Calendar { get; set; }
|
|
public int WeeksInMonth { get; set; }
|
|
} |