فهرست منبع

Add new calendar and deprecate old

Svilen Markov 5 ماه پیش
والد
کامیت
306fb3cb33

+ 33 - 0
internal/glance/static/js/animations.js

@@ -0,0 +1,33 @@
+export const easeOutQuint = 'cubic-bezier(0.22, 1, 0.36, 1)';
+
+export function directions(anim, opt, ...dirs) {
+    return dirs.map(dir => anim({ direction: dir, ...opt }));
+}
+
+export function slideFade({
+    direction = 'left',
+    fill = 'backwards',
+    duration = 200,
+    distance = '1rem',
+    easing = 'ease',
+    offset = 0,
+}) {
+    const axis = direction === 'left' || direction === 'right' ? 'X' : 'Y';
+    const negative = direction === 'left' || direction === 'up' ? '-' : '';
+    const amount = negative + distance;
+
+    return {
+        keyframes: [
+            {
+                offset: offset,
+                opacity: 0,
+                transform: `translate${axis}(${amount})`,
+            }
+        ],
+        options: {
+            duration: duration,
+            easing: easing,
+            fill: fill,
+        },
+    };
+}

+ 212 - 0
internal/glance/static/js/calendar.js

@@ -0,0 +1,212 @@
+import { directions, easeOutQuint, slideFade } from "./animations.js";
+import { elem, repeat, text } from "./templating.js";
+
+const FULL_MONTH_SLOTS = 7*6;
+const WEEKDAY_ABBRS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
+const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
+
+const leftArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
+  <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
+</svg>`;
+
+const rightArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
+  <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
+</svg>`;
+
+const undoArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
+  <path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
+</svg>`;
+
+const [datesExitLeft, datesExitRight] = directions(
+    slideFade, { distance: "2rem", duration: 120, offset: 1 },
+    "left", "right"
+);
+
+const [datesEntranceLeft, datesEntranceRight] = directions(
+    slideFade, { distance: "0.8rem", duration: 500, easing: easeOutQuint },
+    "left", "right"
+);
+
+const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
+
+export default function(element) {
+    element.swap(Calendar(
+        Number(element.dataset.firstDayOfWeek ?? 1)
+    ));
+}
+
+// TODO: when viewing the previous/next month, display the current date if it's within the spill-over days
+function Calendar(firstDay) {
+    let header, dates;
+    let advanceTimeTicker;
+    let now = new Date();
+    let activeDate;
+
+    const update = (newDate) => {
+        header.component.update(now, newDate);
+        dates.component.update(now, newDate);
+        activeDate = newDate;
+    };
+
+    const autoAdvanceNow = () => {
+        advanceTimeTicker = setTimeout(() => {
+            // TODO: don't auto advance if looking at a different month
+            update(now = new Date());
+            autoAdvanceNow();
+        }, msTillNextDay());
+    };
+
+    const adjacentMonth = (dir) => new Date(activeDate.getFullYear(), activeDate.getMonth() + dir, 1);
+    const nextClicked = () => update(adjacentMonth(1));
+    const prevClicked = () => update(adjacentMonth(-1));
+    const undoClicked = () => update(now);
+
+    const calendar = elem().classes("calendar").append(
+        header = Header(nextClicked, prevClicked, undoClicked),
+        dates = Dates(firstDay)
+    );
+
+    update(now);
+    autoAdvanceNow();
+
+    return calendar.component({
+        suspend: () => clearTimeout(advanceTimeTicker)
+    });
+}
+
+function Header(nextClicked, prevClicked, undoClicked) {
+    let month, monthNumber, year, undo;
+    const button = () => elem("button").classes("calendar-header-button");
+
+    const monthAndYear = elem().classes("size-h2", "color-highlight").append(
+        month = text(),
+        " ",
+        year = elem("span").classes("size-h3"),
+        undo = button()
+            .hide()
+            .classes("calendar-undo-button")
+            .attr("title", "Back to current month")
+            .on("click", undoClicked)
+            .html(undoArrowSvg)
+    );
+
+    const monthSwitcher = elem()
+        .classes("flex", "gap-7", "items-center")
+        .append(
+            button()
+                .attr("title", "Previous month")
+                .on("click", prevClicked)
+                .html(leftArrowSvg),
+            monthNumber = elem()
+                .classes("color-highlight")
+                .styles({ marginTop: "0.1rem" }),
+            button()
+                .attr("title", "Next month")
+                .on("click", nextClicked)
+                .html(rightArrowSvg),
+        );
+
+    return elem().classes("flex", "justify-between", "items-center").append(
+        monthAndYear,
+        monthSwitcher
+    ).component({
+        update: function (now, newDate) {
+            month.text(MONTH_NAMES[newDate.getMonth()]);
+            year.text(newDate.getFullYear());
+            const m = newDate.getMonth() + 1;
+            monthNumber.text((m < 10 ? "0" : "") + m);
+
+            if (!datesWithinSameMonth(now, newDate)) {
+                if (undo.isHidden()) undo.show().animate(undoEntrance);
+            } else {
+                undo.hide();
+            }
+
+            return this;
+        }
+    });
+}
+
+function Dates(firstDay) {
+    let dates, lastRenderedDate;
+
+    const updateFullMonth = function(now, newDate) {
+        const firstWeekday = new Date(newDate.getFullYear(), newDate.getMonth(), 1).getDay();
+        const previousMonthSpilloverDays = (firstWeekday - firstDay + 7) % 7 || 7;
+        const currentMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth());
+        const nextMonthSpilloverDays = FULL_MONTH_SLOTS - (previousMonthSpilloverDays + currentMonthDays);
+        const previousMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth() - 1)
+        const isCurrentMonth = datesWithinSameMonth(now, newDate);
+        const currentDate = now.getDate();
+
+        let children = dates.children;
+        let index = 0;
+
+        for (let i = 0; i < FULL_MONTH_SLOTS; i++) {
+            children[i].clearClasses("calendar-spillover-date", "calendar-current-date");
+        }
+
+        for (let i = 0; i < previousMonthSpilloverDays; i++, index++) {
+            children[index].classes("calendar-spillover-date").text(
+                previousMonthDays - previousMonthSpilloverDays + i + 1
+            )
+        }
+
+        for (let i = 1; i <= currentMonthDays; i++, index++) {
+            children[index]
+                .classesIf(isCurrentMonth && i === currentDate, "calendar-current-date")
+                .text(i);
+        }
+
+        for (let i = 0; i < nextMonthSpilloverDays; i++, index++) {
+            children[index].classes("calendar-spillover-date").text(i + 1);
+        }
+
+        lastRenderedDate = newDate;
+    };
+
+    const update = function(now, newDate) {
+        if (lastRenderedDate === undefined || datesWithinSameMonth(newDate, lastRenderedDate)) {
+            updateFullMonth(now, newDate);
+            return;
+        }
+
+        const next = newDate > lastRenderedDate;
+        dates.animateUpdate(
+            () => updateFullMonth(now, newDate),
+            next ? datesExitLeft : datesExitRight,
+            next ? datesEntranceRight : datesEntranceLeft,
+        );
+    }
+
+    return elem().append(
+        elem().classes("calendar-dates", "margin-top-15").append(
+            ...repeat(7, (i) => elem().classes("size-h6", "color-subdue").text(
+                WEEKDAY_ABBRS[(firstDay + i) % 7]
+            ))
+        ),
+
+        dates = elem().classes("calendar-dates", "margin-top-3").append(
+            ...elem().classes("calendar-date").duplicate(FULL_MONTH_SLOTS)
+        )
+    ).component({ update });
+}
+
+function datesWithinSameMonth(d1, d2) {
+    return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth();
+}
+
+function daysInMonth(year, month) {
+    return new Date(year, month + 1, 0).getDate();
+}
+
+function msTillNextDay(now) {
+    now = now || new Date();
+
+    return 86_400_000 - (
+      now.getMilliseconds() +
+      now.getSeconds() * 1000 +
+      now.getMinutes() * 60_000 +
+      now.getHours() * 3_600_000
+    );
+}

+ 12 - 0
internal/glance/static/js/main.js

@@ -625,6 +625,17 @@ function setupClocks() {
     updateClocks();
 }
 
+async function setupCalendars() {
+    const elems = document.getElementsByClassName("calendar");
+    if (elems.length == 0) return;
+
+    // TODO: implement prefetching, currently loads as a nasty waterfall of requests
+    const calendar = await import ('./calendar.js');
+
+    for (let i = 0; i < elems.length; i++)
+        calendar.default(elems[i]);
+}
+
 function setupTruncatedElementTitles() {
     const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
 
@@ -648,6 +659,7 @@ async function setupPage() {
     try {
         setupPopovers();
         setupClocks()
+        await setupCalendars();
         setupCarousels();
         setupSearchBoxes();
         setupCollapsibleLists();

+ 190 - 0
internal/glance/static/js/templating.js

@@ -0,0 +1,190 @@
+export function elem(tag = "div") {
+    return document.createElement(tag);
+}
+
+export function fragment(...children) {
+    const f = document.createDocumentFragment();
+    if (children) f.append(...children);
+    return f;
+}
+
+export function text(str = "") {
+    return document.createTextNode(str);
+}
+
+export function repeat(n, fn) {
+    const elems = Array(n);
+
+    for (let i = 0; i < n; i++)
+        elems[i] = fn(i);
+
+    return elems;
+}
+
+export function find(selector) {
+    return document.querySelector(selector);
+}
+
+export function findAll(selector) {
+    return document.querySelectorAll(selector);
+}
+
+const ep = HTMLElement.prototype;
+const fp = DocumentFragment.prototype;
+const tp = Text.prototype;
+
+ep.classes = function(...classes) {
+    this.classList.add(...classes);
+    return this;
+}
+
+ep.find = function(selector) {
+    return this.querySelector(selector);
+}
+
+ep.findAll = function(selector) {
+    return this.querySelectorAll(selector);
+}
+
+ep.classesIf = function(cond, ...classes) {
+    cond ? this.classList.add(...classes) : this.classList.remove(...classes);
+    return this;
+}
+
+ep.hide = function() {
+    this.style.display = "none";
+    return this;
+}
+
+ep.show = function() {
+    this.style.removeProperty("display");
+    return this;
+}
+
+ep.showIf = function(cond) {
+    cond ? this.show() : this.hide();
+    return this;
+}
+
+ep.isHidden = function() {
+    return this.style.display === "none";
+}
+
+ep.clearClasses = function(...classes) {
+    classes.length ? this.classList.remove(...classes) : this.className = "";
+    return this;
+}
+
+ep.hasClass = function(className) {
+    return this.classList.contains(className);
+}
+
+ep.attr = function(name, value) {
+    this.setAttribute(name, value);
+    return this;
+}
+
+ep.attrs = function(attrs) {
+    for (const [name, value] of Object.entries(attrs))
+        this.setAttribute(name, value);
+    return this;
+}
+
+ep.tap = function(fn) {
+    fn(this);
+    return this;
+}
+
+ep.text = function(text) {
+    this.innerText = text;
+    return this;
+}
+
+ep.html = function(html) {
+    this.innerHTML = html;
+    return this;
+}
+
+ep.appendTo = function(parent) {
+    parent.appendChild(this);
+    return this;
+}
+
+ep.swap = function(element) {
+    this.replaceWith(element);
+    return element;
+}
+
+ep.on = function(event, callback, options) {
+    if (typeof event === "string") {
+        this.addEventListener(event, callback, options);
+        return this;
+    }
+
+    for (let i = 0; i < event.length; i++)
+        this.addEventListener(event[i], callback, options);
+
+    return this;
+}
+
+const epAppend = ep.append;
+ep.append = function(...children) {
+    epAppend.apply(this, children);
+    return this;
+}
+
+ep.duplicate = function(n) {
+    const elems = Array(n);
+
+    for (let i = 0; i < n; i++)
+        elems[i] = this.cloneNode(true);
+
+    return elems;
+}
+
+ep.styles = function(s) {
+    Object.assign(this.style, s);
+    return this;
+}
+
+const epAnimate = ep.animate;
+ep.animate = function(anim, callback) {
+    const a = epAnimate.call(this, anim.keyframes, anim.options);
+    if (callback) a.onfinish = () => callback(this, a);
+    return this;
+}
+
+ep.animateUpdate = function(update, exit, entrance) {
+    this.animate(exit, () => {
+        update(this);
+        this.animate(entrance);
+    });
+
+    return this;
+}
+
+ep.styleVar = function(name, value) {
+    this.style.setProperty(`--${name}`, value);
+    return this;
+}
+
+ep.component = function (methods) {
+    this.component = methods;
+    return this;
+}
+
+const fpAppend = fp.append;
+fp.append = function(...children) {
+    fpAppend.apply(this, children);
+    return this;
+}
+
+fp.appendTo = function(parent) {
+    parent.appendChild(this);
+    return this;
+}
+
+tp.text = function(text) {
+    this.nodeValue = text;
+    return this;
+}

+ 69 - 4
internal/glance/static/main.css

@@ -17,7 +17,7 @@
     --cm: 1;
     --tsm: 1;
 
-    --widget-gap: 25px;
+    --widget-gap: 23px;
     --widget-content-vertical-padding: 15px;
     --widget-content-horizontal-padding: 17px;
     --widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
@@ -294,6 +294,12 @@ pre {
     width: 10px;
 }
 
+*:focus-visible {
+    outline: 2px solid var(--color-primary);
+    outline-offset: 0.1rem;
+    border-radius: var(--border-radius);
+}
+
 *, *::before, *::after {
     box-sizing: border-box;
 }
@@ -1074,19 +1080,78 @@ details[open] .summary::after {
     filter: invert(1);
 }
 
-.calendar-day {
+.old-calendar-day {
     width: calc(100% / 7);
     text-align: center;
     padding: 0.6rem 0;
 }
 
-
-.calendar-day-today {
+.old-calendar-day-today {
     border-radius: var(--border-radius);
     background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
     color: var(--color-text-highlight);
 }
 
+.calendar-dates {
+    text-align: center;
+    display: grid;
+    grid-template-columns: repeat(7, 1fr);
+    gap: 2px;
+}
+
+.calendar-date {
+    padding: 0.4rem 0;
+    color: var(--color-text-paragraph);
+    position: relative;
+    border-radius: var(--border-radius);
+    background: none;
+    border: none;
+    font: inherit;
+}
+
+.calendar-current-date {
+    border-radius: var(--border-radius);
+    background-color: var(--color-popover-border);
+    color: var(--color-text-highlight);
+}
+
+.calendar-spillover-date {
+    color: var(--color-text-subdue);
+}
+
+.calendar-header-button {
+    position: relative;
+    cursor: pointer;
+    width: 2rem;
+    height: 2rem;
+    z-index: 1;
+    background: none;
+    border: none;
+}
+
+.calendar-header-button::before {
+    content: '';
+    position: absolute;
+    inset: -0.2rem;
+    border-radius: var(--border-radius);
+    background-color: var(--color-text-subdue);
+    opacity: 0;
+    transition: opacity 0.2s;
+    z-index: -1;
+}
+
+.calendar-header-button:hover::before {
+    opacity: 0.4;
+}
+
+.calendar-undo-button {
+    display: inline-block;
+    vertical-align: text-top;
+    width: 2rem;
+    height: 2rem;
+    margin-left: 0.7rem;
+}
+
 .dns-stats-totals {
     transition: opacity .3s;
     transition-delay: 50ms;

+ 1 - 28
internal/glance/templates/calendar.html

@@ -2,33 +2,6 @@
 
 {{ define "widget-content" }}
 <div class="widget-small-content-bounds">
-    <div class="flex justify-between items-center">
-        <div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
-        <ul class="list-horizontal-text color-highlight size-h4">
-            <li>Week {{ .Calendar.CurrentWeekNumber }}</li>
-            <li>{{ .Calendar.CurrentYear }}</li>
-        </ul>
-    </div>
-
-    <div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
-        {{ if .StartSunday }}
-            <div class="calendar-day">Su</div>
-        {{ end }}
-        <div class="calendar-day">Mo</div>
-        <div class="calendar-day">Tu</div>
-        <div class="calendar-day">We</div>
-        <div class="calendar-day">Th</div>
-        <div class="calendar-day">Fr</div>
-        <div class="calendar-day">Sa</div>
-        {{ if not .StartSunday }}
-            <div class="calendar-day">Su</div>
-        {{ end }}
-    </div>
-
-    <div class="flex flex-wrap">
-        {{ range .Calendar.Days }}
-        <div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
-        {{ end }}
-    </div>
+    <div class="calendar" data-first-day-of-week="{{ .FirstDay }}"></div>
 </div>
 {{ end }}

+ 34 - 0
internal/glance/templates/old-calendar.html

@@ -0,0 +1,34 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<div class="widget-small-content-bounds">
+    <div class="flex justify-between items-center">
+        <div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
+        <ul class="list-horizontal-text color-highlight size-h4">
+            <li>Week {{ .Calendar.CurrentWeekNumber }}</li>
+            <li>{{ .Calendar.CurrentYear }}</li>
+        </ul>
+    </div>
+
+    <div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
+        {{ if .StartSunday }}
+            <div class="old-calendar-day">Su</div>
+        {{ end }}
+        <div class="old-calendar-day">Mo</div>
+        <div class="old-calendar-day">Tu</div>
+        <div class="old-calendar-day">We</div>
+        <div class="old-calendar-day">Th</div>
+        <div class="old-calendar-day">Fr</div>
+        <div class="old-calendar-day">Sa</div>
+        {{ if not .StartSunday }}
+            <div class="old-calendar-day">Su</div>
+        {{ end }}
+    </div>
+
+    <div class="flex flex-wrap">
+        {{ range .Calendar.Days }}
+        <div class="old-calendar-day{{ if eq . $.Calendar.CurrentDay }} old-calendar-day-today{{ end }}">{{ . }}</div>
+        {{ end }}
+    </div>
+</div>
+{{ end }}

+ 25 - 66
internal/glance/widget-calendar.go

@@ -1,86 +1,45 @@
 package glance
 
 import (
-	"context"
+	"errors"
 	"html/template"
 	"time"
 )
 
 var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html")
 
-type calendarWidget struct {
-	widgetBase  `yaml:",inline"`
-	Calendar    *calendar
-	StartSunday bool `yaml:"start-sunday"`
-}
-
-func (widget *calendarWidget) initialize() error {
-	widget.withTitle("Calendar").withCacheOnTheHour()
-
-	return nil
+var calendarWeekdaysToInt = map[string]time.Weekday{
+	"sunday":    time.Sunday,
+	"monday":    time.Monday,
+	"tuesday":   time.Tuesday,
+	"wednesday": time.Wednesday,
+	"thursday":  time.Thursday,
+	"friday":    time.Friday,
+	"saturday":  time.Saturday,
 }
 
-func (widget *calendarWidget) update(ctx context.Context) {
-	widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
-	widget.withError(nil).scheduleNextUpdate()
-}
-
-func (widget *calendarWidget) Render() template.HTML {
-	return widget.renderTemplate(widget, calendarWidgetTemplate)
-}
-
-type calendar struct {
-	CurrentDay        int
-	CurrentWeekNumber int
-	CurrentMonthName  string
-	CurrentYear       int
-	Days              []int
+type calendarWidget struct {
+	widgetBase     `yaml:",inline"`
+	FirstDayOfWeek string        `yaml:"first-day-of-week"`
+	FirstDay       int           `yaml:"-"`
+	cachedHTML     template.HTML `yaml:"-"`
 }
 
-// TODO: very inflexible, refactor to allow more customizability
-// TODO: allow changing between showing the previous and next week and the entire month
-func newCalendar(now time.Time, startSunday bool) *calendar {
-	year, week := now.ISOWeek()
-	weekday := now.Weekday()
-	if !startSunday {
-		weekday = (weekday + 6) % 7 // Shift Monday to 0
-	}
-
-	currentMonthDays := daysInMonth(now.Month(), year)
-
-	var previousMonthDays int
+func (widget *calendarWidget) initialize() error {
+	widget.withTitle("Calendar").withError(nil)
 
-	if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
-		previousMonthDays = daysInMonth(12, year-1)
-	} else {
-		previousMonthDays = daysInMonth(previousMonthNumber, year)
+	if widget.FirstDayOfWeek == "" {
+		widget.FirstDayOfWeek = "monday"
+	} else if _, ok := calendarWeekdaysToInt[widget.FirstDayOfWeek]; !ok {
+		return errors.New("invalid first day of week")
 	}
 
-	startDaysFrom := now.Day() - int(weekday) - 7
-
-	days := make([]int, 21)
-
-	for i := 0; i < 21; i++ {
-		day := startDaysFrom + i
+	widget.FirstDay = int(calendarWeekdaysToInt[widget.FirstDayOfWeek])
+	widget.cachedHTML = widget.renderTemplate(widget, calendarWidgetTemplate)
 
-		if day < 1 {
-			day = previousMonthDays + day
-		} else if day > currentMonthDays {
-			day = day - currentMonthDays
-		}
-
-		days[i] = day
-	}
-
-	return &calendar{
-		CurrentDay:        now.Day(),
-		CurrentWeekNumber: week,
-		CurrentMonthName:  now.Month().String(),
-		CurrentYear:       year,
-		Days:              days,
-	}
+	return nil
 }
 
-func daysInMonth(m time.Month, year int) int {
-	return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
+func (widget *calendarWidget) Render() template.HTML {
+	return widget.cachedHTML
 }

+ 86 - 0
internal/glance/widget-old-calendar.go

@@ -0,0 +1,86 @@
+package glance
+
+import (
+	"context"
+	"html/template"
+	"time"
+)
+
+var oldCalendarWidgetTemplate = mustParseTemplate("old-calendar.html", "widget-base.html")
+
+type oldCalendarWidget struct {
+	widgetBase  `yaml:",inline"`
+	Calendar    *calendar
+	StartSunday bool `yaml:"start-sunday"`
+}
+
+func (widget *oldCalendarWidget) initialize() error {
+	widget.withTitle("Calendar").withCacheOnTheHour()
+
+	return nil
+}
+
+func (widget *oldCalendarWidget) update(ctx context.Context) {
+	widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
+	widget.withError(nil).scheduleNextUpdate()
+}
+
+func (widget *oldCalendarWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, oldCalendarWidgetTemplate)
+}
+
+type calendar struct {
+	CurrentDay        int
+	CurrentWeekNumber int
+	CurrentMonthName  string
+	CurrentYear       int
+	Days              []int
+}
+
+// TODO: very inflexible, refactor to allow more customizability
+// TODO: allow changing between showing the previous and next week and the entire month
+func newCalendar(now time.Time, startSunday bool) *calendar {
+	year, week := now.ISOWeek()
+	weekday := now.Weekday()
+	if !startSunday {
+		weekday = (weekday + 6) % 7 // Shift Monday to 0
+	}
+
+	currentMonthDays := daysInMonth(now.Month(), year)
+
+	var previousMonthDays int
+
+	if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
+		previousMonthDays = daysInMonth(12, year-1)
+	} else {
+		previousMonthDays = daysInMonth(previousMonthNumber, year)
+	}
+
+	startDaysFrom := now.Day() - int(weekday) - 7
+
+	days := make([]int, 21)
+
+	for i := 0; i < 21; i++ {
+		day := startDaysFrom + i
+
+		if day < 1 {
+			day = previousMonthDays + day
+		} else if day > currentMonthDays {
+			day = day - currentMonthDays
+		}
+
+		days[i] = day
+	}
+
+	return &calendar{
+		CurrentDay:        now.Day(),
+		CurrentWeekNumber: week,
+		CurrentMonthName:  now.Month().String(),
+		CurrentYear:       year,
+		Days:              days,
+	}
+}
+
+func daysInMonth(m time.Month, year int) int {
+	return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
+}

+ 2 - 0
internal/glance/widget.go

@@ -23,6 +23,8 @@ func newWidget(widgetType string) (widget, error) {
 	switch widgetType {
 	case "calendar":
 		w = &calendarWidget{}
+	case "calendar-legacy":
+		w = &oldCalendarWidget{}
 	case "clock":
 		w = &clockWidget{}
 	case "weather":