Week Widget
The year as 52 dots, one per week. The filled ones are already behind you.
the year 52 dots
About a minute to set up — steps below.
Weeks you have lived are filled in their season colour, this week is white, the rest stay grey. Month ends are squared off, so the grid still reads like a calendar.
Setup
-
Get Scriptable
It's free on the App Store. It's the app that runs the script and draws the widget.
-
Save the script
Tap Download above, open the file from Safari's downloads, then Share › Save to Files › iCloud Drive › Scriptable. It shows up in the app right away.
No iCloud? Tap Copy code, open Scriptable, tap +, paste, and name it Week Widget.
-
Add the widget
Long-press the home screen, tap Edit › Add Widget, find Scriptable, and pick the small size.
-
Point it at the script
Long-press the widget, tap Edit Widget, and choose Week Widget. Leave Parameter empty.
Good to know
- Seasons are meteorological, so they start on the 1st of March, June, September, and December.
- Handles 53-week years and the odd late-December week that belongs to the next one.
- No account, no network, nothing to set up. It redraws itself after midnight.
Source
week-widget-small.js
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: teal; icon-glyph: magic;
const now = new Date()
const year = now.getFullYear()
// ---------- DST-safe date helpers ----------
function startOfDay(date) {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d
}
// Math.round absorbs the ±1h DST shift that would otherwise
// make Math.ceil jump by a full day.
function daysBetween(from, to) {
return Math.round((startOfDay(to) - startOfDay(from)) / 86400000)
}
function getISOWeek(date) {
const d = startOfDay(date)
d.setDate(d.getDate() + 4 - (d.getDay() || 7)) // Thursday of this ISO week
const yearStart = new Date(d.getFullYear(), 0, 1)
return Math.floor(Math.round((d - yearStart) / 86400000) / 7) + 1
}
function weeksInISOYear(y) {
return getISOWeek(new Date(y, 11, 28)) // Dec 28 is always in the last ISO week
}
function thursdayOfISOWeek(y, week) {
const jan4 = new Date(y, 0, 4)
const thu = startOfDay(jan4)
thu.setDate(jan4.getDate() + 4 - (jan4.getDay() || 7))
thu.setDate(thu.getDate() + (week - 1) * 7)
return startOfDay(thu)
}
// ---------- Styling ----------
const textColor = new Color("#FFFFFF")
const subtleColor = new Color("#8E8E93")
const dotInactive = new Color("#3A3A3C")
const trackColor = new Color("#FFFFFF", 0.12)
const seasons = {
winter: new Color("#64D2FF"),
spring: new Color("#30D158"),
summer: new Color("#FFD60A"),
autumn: new Color("#FF9F0A")
}
// ---------- Seasons ----------
// Meteorological seasons — the convention SMHI and most of Sweden uses.
// Boundaries land on month ends, so they line up with the square month-end dots.
const SEASON_STARTS = [[2, 1], [5, 1], [8, 1], [11, 1]]
function seasonStarts(y) {
return SEASON_STARTS.map(([m, d]) => new Date(y, m, d))
}
function buildSeasons(y) {
const [spring, summer, autumn, winter] = seasonStarts(y)
return [
{ name: "winter", start: seasonStarts(y - 1)[3], end: spring, color: seasons.winter },
{ name: "spring", start: spring, end: summer, color: seasons.spring },
{ name: "summer", start: summer, end: autumn, color: seasons.summer },
{ name: "autumn", start: autumn, end: winter, color: seasons.autumn },
{ name: "winter", start: winter, end: seasonStarts(y + 1)[0], color: seasons.winter }
]
}
const seasonList = buildSeasons(year)
function seasonFor(date) {
const d = startOfDay(date)
for (const s of seasonList) {
if (d >= s.start && d < s.end) return s
}
return seasonList[0]
}
// A week takes the season of its Thursday — same source of truth
// as the header, so the two can never disagree.
function getSeasonColor(week) {
return seasonFor(thursdayOfISOWeek(year, week)).color
}
// ---------- Current state ----------
const totalWeeks = weeksInISOYear(year) // 52 or 53
const weekNumber = getISOWeek(now)
// Late December can report ISO week 1 (belonging to next year).
// Keep the display honest, but clamp the grid index.
const gridWeek = weekNumber === 1 && now.getMonth() === 11 ? totalWeeks : weekNumber
const currentSeason = seasonFor(now)
const daysLeftInSeason = daysBetween(now, currentSeason.end) // includes today
const seasonName =
currentSeason.name.charAt(0).toUpperCase() + currentSeason.name.slice(1)
// Year progress
const jan1 = new Date(year, 0, 1)
const daysInYear = daysBetween(jan1, new Date(year + 1, 0, 1))
const dayOfYear = daysBetween(jan1, now) + 1
const yearProgress = dayOfYear / daysInYear
const yearPercent = Math.round(yearProgress * 100)
function getMonthEndWeeks(y) {
const set = new Set()
for (let m = 0; m < 12; m++) {
let w = getISOWeek(new Date(y, m + 1, 0))
if (m === 11 && w === 1) w = totalWeeks // Dec 31 can land in next year's week 1
set.add(w)
}
return set
}
const monthEndWeeks = getMonthEndWeeks(year)
// ---------- Build widget ----------
const widget = new ListWidget()
widget.setPadding(13, 14, 11, 14)
const gradient = new LinearGradient()
gradient.colors = [new Color("#232326"), new Color("#131315")]
gradient.locations = [0, 1]
gradient.startPoint = new Point(0, 0)
gradient.endPoint = new Point(0.4, 1)
widget.backgroundGradient = gradient
const label = widget.addText("WEEK")
label.font = Font.boldSystemFont(10)
label.textColor = subtleColor
const number = widget.addText(String(weekNumber))
number.font = Font.boldRoundedSystemFont(38)
number.textColor = currentSeason.color
widget.addSpacer(5)
// Dot grid — sized to fill the small widget's inner width (~127pt)
const rows = 4
const columns = Math.ceil(totalWeeks / rows) // 13 for 52 weeks, 14 for 53
const dotSpacing = 2
const dotSize = Math.floor((127 - dotSpacing * (columns - 1)) / columns)
for (let row = 0; row < rows; row++) {
const rowStack = widget.addStack()
rowStack.layoutHorizontally()
rowStack.spacing = dotSpacing
for (let col = 0; col < columns; col++) {
const weekIndex = row * columns + col + 1
if (weekIndex > totalWeeks) break
const dot = rowStack.addStack()
dot.size = new Size(dotSize, dotSize)
dot.cornerRadius = monthEndWeeks.has(weekIndex) ? 1 : dotSize / 2
if (weekIndex < gridWeek) {
dot.backgroundColor = getSeasonColor(weekIndex)
} else if (weekIndex === gridWeek) {
dot.backgroundColor = textColor
} else {
dot.backgroundColor = dotInactive
}
}
if (row < rows - 1) widget.addSpacer(dotSpacing)
}
widget.addSpacer(5)
const season = widget.addText(`${daysLeftInSeason} days of ${seasonName} left`)
season.font = Font.mediumSystemFont(10)
season.textColor = currentSeason.color
season.lineLimit = 1
season.minimumScaleFactor = 0.75
widget.addSpacer(5)
// Year progress bar
const footer = widget.addStack()
footer.layoutHorizontally()
footer.centerAlignContent()
footer.spacing = 6
const barWidth = 88
const barHeight = 3
const track = footer.addStack()
track.size = new Size(barWidth, barHeight)
track.cornerRadius = barHeight / 2
track.backgroundColor = trackColor
const fill = track.addStack()
fill.size = new Size(Math.max(barHeight, barWidth * yearProgress), barHeight)
fill.cornerRadius = barHeight / 2
fill.backgroundColor = currentSeason.color
const pct = footer.addText(`${yearPercent}%`)
pct.font = Font.mediumSystemFont(9)
pct.textColor = subtleColor
// ---------- Refresh ----------
// Nothing here changes until midnight. iOS treats this as a hint.
const nextMidnight = startOfDay(now)
nextMidnight.setDate(nextMidnight.getDate() + 1)
widget.refreshAfterDate = nextMidnight
if (config.runsInApp) widget.presentSmall()
Script.setWidget(widget)
Script.complete()