Small iOS widget showing week 35 above a grid of season-coloured dots, with days of summer left and a year progress bar.

Week Widget

The year as 52 dots, one per week. The filled ones are already behind you.

the year 52 dots Scriptable · small widget

Download

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

  1. Get Scriptable

    It's free on the App Store. It's the app that runs the script and draws the widget.

  2. Save the script

    Tap Download above, open the file from Safari's downloads, then ShareSave to FilesiCloud Drive › Scriptable. It shows up in the app right away.

    No iCloud? Tap Copy code, open Scriptable, tap +, paste, and name it Week Widget.

  3. Add the widget

    Long-press the home screen, tap EditAdd Widget, find Scriptable, and pick the small size.

  4. 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()