component — date & time

Year View

A whole year as twelve month cards, in three variants: heatmap, where a day's fill steps with how much is on it against the year's busiest day (never less than four to a step, so a quiet year is not painted full) under a Fewer/More legend; count, a neutral cell with the exact number; and list, larger cells naming the day's first events in their calendar's colour and folding the rest into the same "+N more" panel Month View opens. It reads the same CalendarEvent and CalendarSource as every other view, or a plain { "YYYY-MM-DD": n } record when the year is an aggregate. Days with something on them and month headings link wherever dayHref / monthHref say, or call onDayAction / onMonthAction; empty days are inert. The year steps from a CalendarToolbar whose heading opens a Year Picker, and weekday initials, the first day of the week and every number follow the locale.

  • calendar
  • year
  • heatmap
  • overview
  • events
  • counts
  • activity

06 examples

Heatmap

The default. A day's fill steps with how many events touch it, against the year's busiest day — but never fewer than one event to a step, so a quiet year is not painted full. Days with something on them and the month headings are links; give them somewhere to go with dayHref / monthHref or onDayAction / onMonthAction.

Januar

3 events on Do., 1. Jan. 20264 events on Fr., 2. Jan. 20261 event on Mo., 5. Jan. 20262 events on Di., 6. Jan. 20263 events on Mi., 7. Jan. 20264 events on Do., 8. Jan. 20265 events on Fr., 9. Jan. 20262 events on Mo., 12. Jan. 20263 events on Di., 13. Jan. 20264 events on Mi., 14. Jan. 20265 events on Do., 15. Jan. 20263 events on Mo., 19. Jan. 20264 events on Di., 20. Jan. 20265 events on Mi., 21. Jan. 20261 event on Fr., 23. Jan. 20264 events on Mo., 26. Jan. 20265 events on Di., 27. Jan. 20261 event on Do., 29. Jan. 20262 events on Fr., 30. Jan. 2026

Februar

2 events on Mo., 2. Feb. 20263 events on Di., 3. Feb. 20264 events on Mi., 4. Feb. 20265 events on Do., 5. Feb. 20263 events on Mo., 9. Feb. 20264 events on Di., 10. Feb. 20265 events on Mi., 11. Feb. 20261 event on Fr., 13. Feb. 20264 events on Mo., 16. Feb. 20265 events on Di., 17. Feb. 20261 event on Do., 19. Feb. 20262 events on Fr., 20. Feb. 20265 events on Mo., 23. Feb. 20261 event on Mi., 25. Feb. 20262 events on Do., 26. Feb. 20263 events on Fr., 27. Feb. 2026

März

3 events on Mo., 2. März 20264 events on Di., 3. März 20265 events on Mi., 4. März 20261 event on Fr., 6. März 20264 events on Mo., 9. März 20265 events on Di., 10. März 20261 event on Do., 12. März 20262 events on Fr., 13. März 20265 events on Mo., 16. März 20261 event on Mi., 18. März 20262 events on Do., 19. März 20263 events on Fr., 20. März 20261 event on Di., 24. März 20262 events on Mi., 25. März 20263 events on Do., 26. März 20264 events on Fr., 27. März 20261 event on Mo., 30. März 20262 events on Di., 31. März 2026

April

1 event on Do., 2. Apr. 20262 events on Fr., 3. Apr. 20265 events on Mo., 6. Apr. 20261 event on Mi., 8. Apr. 20262 events on Do., 9. Apr. 20263 events on Fr., 10. Apr. 20261 event on Di., 14. Apr. 20262 events on Mi., 15. Apr. 20263 events on Do., 16. Apr. 20264 events on Fr., 17. Apr. 20261 event on Mo., 20. Apr. 20262 events on Di., 21. Apr. 20263 events on Mi., 22. Apr. 20264 events on Do., 23. Apr. 20265 events on Fr., 24. Apr. 20262 events on Mo., 27. Apr. 20263 events on Di., 28. Apr. 20264 events on Mi., 29. Apr. 20265 events on Do., 30. Apr. 2026

Mai

3 events on Fr., 1. Mai 20261 event on Di., 5. Mai 20262 events on Mi., 6. Mai 20263 events on Do., 7. Mai 20264 events on Fr., 8. Mai 20261 event on Mo., 11. Mai 20262 events on Di., 12. Mai 20263 events on Mi., 13. Mai 20264 events on Do., 14. Mai 20265 events on Fr., 15. Mai 20262 events on Mo., 18. Mai 20263 events on Di., 19. Mai 20264 events on Mi., 20. Mai 20265 events on Do., 21. Mai 20263 events on Mo., 25. Mai 20264 events on Di., 26. Mai 20265 events on Mi., 27. Mai 20261 event on Fr., 29. Mai 2026

Juni

1 event on Mo., 1. Juni 20262 events on Di., 2. Juni 20263 events on Mi., 3. Juni 20264 events on Do., 4. Juni 20265 events on Fr., 5. Juni 20262 events on Mo., 8. Juni 20263 events on Di., 9. Juni 20264 events on Mi., 10. Juni 20265 events on Do., 11. Juni 20263 events on Mo., 15. Juni 20264 events on Di., 16. Juni 20265 events on Mi., 17. Juni 20261 event on Fr., 19. Juni 20264 events on Mo., 22. Juni 20265 events on Di., 23. Juni 20261 event on Do., 25. Juni 20262 events on Fr., 26. Juni 20265 events on Mo., 29. Juni 2026

Juli

4 events on Mi., 1. Juli 20265 events on Do., 2. Juli 20263 events on Mo., 6. Juli 20264 events on Di., 7. Juli 20265 events on Mi., 8. Juli 20261 event on Fr., 10. Juli 20264 events on Mo., 13. Juli 20265 events on Di., 14. Juli 20261 event on Do., 16. Juli 20262 events on Fr., 17. Juli 20265 events on Mo., 20. Juli 20261 event on Mi., 22. Juli 20262 events on Do., 23. Juli 20263 events on Fr., 24. Juli 20261 event on Di., 28. Juli 20262 events on Mi., 29. Juli 20263 events on Do., 30. Juli 20264 events on Fr., 31. Juli 2026

August

September

1 event on Mi., 2. Sept. 20262 events on Do., 3. Sept. 20263 events on Fr., 4. Sept. 20261 event on Di., 8. Sept. 20262 events on Mi., 9. Sept. 20263 events on Do., 10. Sept. 20264 events on Fr., 11. Sept. 20261 event on Mo., 14. Sept. 20262 events on Di., 15. Sept. 20263 events on Mi., 16. Sept. 20264 events on Do., 17. Sept. 20265 events on Fr., 18. Sept. 20262 events on Mo., 21. Sept. 20263 events on Di., 22. Sept. 20264 events on Mi., 23. Sept. 20265 events on Do., 24. Sept. 20263 events on Mo., 28. Sept. 20264 events on Di., 29. Sept. 20265 events on Mi., 30. Sept. 2026

Oktober

3 events on Do., 1. Okt. 20264 events on Fr., 2. Okt. 20261 event on Mo., 5. Okt. 20262 events on Di., 6. Okt. 20263 events on Mi., 7. Okt. 20264 events on Do., 8. Okt. 20265 events on Fr., 9. Okt. 20262 events on Mo., 12. Okt. 20263 events on Di., 13. Okt. 20264 events on Mi., 14. Okt. 20265 events on Do., 15. Okt. 20263 events on Mo., 19. Okt. 20264 events on Di., 20. Okt. 20265 events on Mi., 21. Okt. 20261 event on Fr., 23. Okt. 20264 events on Mo., 26. Okt. 20265 events on Di., 27. Okt. 20261 event on Do., 29. Okt. 20262 events on Fr., 30. Okt. 2026

November

2 events on Mo., 2. Nov. 20263 events on Di., 3. Nov. 20264 events on Mi., 4. Nov. 20265 events on Do., 5. Nov. 20263 events on Mo., 9. Nov. 20264 events on Di., 10. Nov. 20265 events on Mi., 11. Nov. 20261 event on Fr., 13. Nov. 20264 events on Mo., 16. Nov. 20265 events on Di., 17. Nov. 20261 event on Do., 19. Nov. 20262 events on Fr., 20. Nov. 20265 events on Mo., 23. Nov. 20261 event on Mi., 25. Nov. 20262 events on Do., 26. Nov. 20263 events on Fr., 27. Nov. 2026

Dezember

4 events on Di., 1. Dez. 20265 events on Mi., 2. Dez. 20261 event on Fr., 4. Dez. 20264 events on Mo., 7. Dez. 20265 events on Di., 8. Dez. 20261 event on Do., 10. Dez. 20262 events on Fr., 11. Dez. 20265 events on Mo., 14. Dez. 20261 event on Mi., 16. Dez. 20262 events on Do., 17. Dez. 20263 events on Fr., 18. Dez. 20261 event on Di., 22. Dez. 20262 events on Mi., 23. Dez. 20263 events on Do., 24. Dez. 20264 events on Fr., 25. Dez. 20261 event on Mo., 28. Dez. 20262 events on Di., 29. Dez. 20263 events on Mi., 30. Dez. 20264 events on Do., 31. Dez. 2026
FewerMore(up to 5 in a day)

Press a day or a month.

Count

The exact number instead of a shade, for when 3 or 4 matters more than the shape of the year. A caption says what the number counts.

Januar

3 events on Do., 1. Jan. 20264 events on Fr., 2. Jan. 20261 event on Mo., 5. Jan. 20262 events on Di., 6. Jan. 20263 events on Mi., 7. Jan. 20264 events on Do., 8. Jan. 20265 events on Fr., 9. Jan. 20262 events on Mo., 12. Jan. 20263 events on Di., 13. Jan. 20264 events on Mi., 14. Jan. 20265 events on Do., 15. Jan. 20263 events on Mo., 19. Jan. 20264 events on Di., 20. Jan. 20265 events on Mi., 21. Jan. 20261 event on Fr., 23. Jan. 20264 events on Mo., 26. Jan. 20265 events on Di., 27. Jan. 20261 event on Do., 29. Jan. 20262 events on Fr., 30. Jan. 2026

Februar

2 events on Mo., 2. Feb. 20263 events on Di., 3. Feb. 20264 events on Mi., 4. Feb. 20265 events on Do., 5. Feb. 20263 events on Mo., 9. Feb. 20264 events on Di., 10. Feb. 20265 events on Mi., 11. Feb. 20261 event on Fr., 13. Feb. 20264 events on Mo., 16. Feb. 20265 events on Di., 17. Feb. 20261 event on Do., 19. Feb. 20262 events on Fr., 20. Feb. 20265 events on Mo., 23. Feb. 20261 event on Mi., 25. Feb. 20262 events on Do., 26. Feb. 20263 events on Fr., 27. Feb. 2026

März

3 events on Mo., 2. März 20264 events on Di., 3. März 20265 events on Mi., 4. März 20261 event on Fr., 6. März 20264 events on Mo., 9. März 20265 events on Di., 10. März 20261 event on Do., 12. März 20262 events on Fr., 13. März 20265 events on Mo., 16. März 20261 event on Mi., 18. März 20262 events on Do., 19. März 20263 events on Fr., 20. März 20261 event on Di., 24. März 20262 events on Mi., 25. März 20263 events on Do., 26. März 20264 events on Fr., 27. März 20261 event on Mo., 30. März 20262 events on Di., 31. März 2026

April

1 event on Do., 2. Apr. 20262 events on Fr., 3. Apr. 20265 events on Mo., 6. Apr. 20261 event on Mi., 8. Apr. 20262 events on Do., 9. Apr. 20263 events on Fr., 10. Apr. 20261 event on Di., 14. Apr. 20262 events on Mi., 15. Apr. 20263 events on Do., 16. Apr. 20264 events on Fr., 17. Apr. 20261 event on Mo., 20. Apr. 20262 events on Di., 21. Apr. 20263 events on Mi., 22. Apr. 20264 events on Do., 23. Apr. 20265 events on Fr., 24. Apr. 20262 events on Mo., 27. Apr. 20263 events on Di., 28. Apr. 20264 events on Mi., 29. Apr. 20265 events on Do., 30. Apr. 2026

Mai

3 events on Fr., 1. Mai 20261 event on Di., 5. Mai 20262 events on Mi., 6. Mai 20263 events on Do., 7. Mai 20264 events on Fr., 8. Mai 20261 event on Mo., 11. Mai 20262 events on Di., 12. Mai 20263 events on Mi., 13. Mai 20264 events on Do., 14. Mai 20265 events on Fr., 15. Mai 20262 events on Mo., 18. Mai 20263 events on Di., 19. Mai 20264 events on Mi., 20. Mai 20265 events on Do., 21. Mai 20263 events on Mo., 25. Mai 20264 events on Di., 26. Mai 20265 events on Mi., 27. Mai 20261 event on Fr., 29. Mai 2026

Juni

1 event on Mo., 1. Juni 20262 events on Di., 2. Juni 20263 events on Mi., 3. Juni 20264 events on Do., 4. Juni 20265 events on Fr., 5. Juni 20262 events on Mo., 8. Juni 20263 events on Di., 9. Juni 20264 events on Mi., 10. Juni 20265 events on Do., 11. Juni 20263 events on Mo., 15. Juni 20264 events on Di., 16. Juni 20265 events on Mi., 17. Juni 20261 event on Fr., 19. Juni 20264 events on Mo., 22. Juni 20265 events on Di., 23. Juni 20261 event on Do., 25. Juni 20262 events on Fr., 26. Juni 20265 events on Mo., 29. Juni 2026

Juli

4 events on Mi., 1. Juli 20265 events on Do., 2. Juli 20263 events on Mo., 6. Juli 20264 events on Di., 7. Juli 20265 events on Mi., 8. Juli 20261 event on Fr., 10. Juli 20264 events on Mo., 13. Juli 20265 events on Di., 14. Juli 20261 event on Do., 16. Juli 20262 events on Fr., 17. Juli 20265 events on Mo., 20. Juli 20261 event on Mi., 22. Juli 20262 events on Do., 23. Juli 20263 events on Fr., 24. Juli 20261 event on Di., 28. Juli 20262 events on Mi., 29. Juli 20263 events on Do., 30. Juli 20264 events on Fr., 31. Juli 2026

August

September

1 event on Mi., 2. Sept. 20262 events on Do., 3. Sept. 20263 events on Fr., 4. Sept. 20261 event on Di., 8. Sept. 20262 events on Mi., 9. Sept. 20263 events on Do., 10. Sept. 20264 events on Fr., 11. Sept. 20261 event on Mo., 14. Sept. 20262 events on Di., 15. Sept. 20263 events on Mi., 16. Sept. 20264 events on Do., 17. Sept. 20265 events on Fr., 18. Sept. 20262 events on Mo., 21. Sept. 20263 events on Di., 22. Sept. 20264 events on Mi., 23. Sept. 20265 events on Do., 24. Sept. 20263 events on Mo., 28. Sept. 20264 events on Di., 29. Sept. 20265 events on Mi., 30. Sept. 2026

Oktober

3 events on Do., 1. Okt. 20264 events on Fr., 2. Okt. 20261 event on Mo., 5. Okt. 20262 events on Di., 6. Okt. 20263 events on Mi., 7. Okt. 20264 events on Do., 8. Okt. 20265 events on Fr., 9. Okt. 20262 events on Mo., 12. Okt. 20263 events on Di., 13. Okt. 20264 events on Mi., 14. Okt. 20265 events on Do., 15. Okt. 20263 events on Mo., 19. Okt. 20264 events on Di., 20. Okt. 20265 events on Mi., 21. Okt. 20261 event on Fr., 23. Okt. 20264 events on Mo., 26. Okt. 20265 events on Di., 27. Okt. 20261 event on Do., 29. Okt. 20262 events on Fr., 30. Okt. 2026

November

2 events on Mo., 2. Nov. 20263 events on Di., 3. Nov. 20264 events on Mi., 4. Nov. 20265 events on Do., 5. Nov. 20263 events on Mo., 9. Nov. 20264 events on Di., 10. Nov. 20265 events on Mi., 11. Nov. 20261 event on Fr., 13. Nov. 20264 events on Mo., 16. Nov. 20265 events on Di., 17. Nov. 20261 event on Do., 19. Nov. 20262 events on Fr., 20. Nov. 20265 events on Mo., 23. Nov. 20261 event on Mi., 25. Nov. 20262 events on Do., 26. Nov. 20263 events on Fr., 27. Nov. 2026

Dezember

4 events on Di., 1. Dez. 20265 events on Mi., 2. Dez. 20261 event on Fr., 4. Dez. 20264 events on Mo., 7. Dez. 20265 events on Di., 8. Dez. 20261 event on Do., 10. Dez. 20262 events on Fr., 11. Dez. 20265 events on Mo., 14. Dez. 20261 event on Mi., 16. Dez. 20262 events on Do., 17. Dez. 20263 events on Fr., 18. Dez. 20261 event on Di., 22. Dez. 20262 events on Mi., 23. Dez. 20263 events on Do., 24. Dez. 20264 events on Fr., 25. Dez. 20261 event on Mo., 28. Dez. 20262 events on Di., 29. Dez. 20263 events on Mi., 30. Dez. 20264 events on Do., 31. Dez. 2026

The number on a day is how many events it has.

List with +N more

Larger cells naming each day's first events in their calendar's colour; the rest fold into the same "+N more" panel Month View opens. Fewer cards to a row, because a title needs width.

Januar

3 events on Do., 1. Jan. 2026Check-upVisit
4 events on Fr., 2. Jan. 2026VisitCall
1 event on Mo., 5. Jan. 2026Lab
2 events on Di., 6. Jan. 2026ShiftCheck-up
3 events on Mi., 7. Jan. 2026Check-upVisit
4 events on Do., 8. Jan. 2026VisitCall
5 events on Fr., 9. Jan. 2026CallReview
2 events on Mo., 12. Jan. 2026ShiftCheck-up
3 events on Di., 13. Jan. 2026Check-upVisit
4 events on Mi., 14. Jan. 2026VisitCall
5 events on Do., 15. Jan. 2026CallReview
3 events on Mo., 19. Jan. 2026Check-upVisit
4 events on Di., 20. Jan. 2026VisitCall
5 events on Mi., 21. Jan. 2026CallReview
1 event on Fr., 23. Jan. 2026Lab
4 events on Mo., 26. Jan. 2026VisitCall
5 events on Di., 27. Jan. 2026CallReview
1 event on Do., 29. Jan. 2026Lab
2 events on Fr., 30. Jan. 2026ShiftCheck-up

Februar

2 events on Mo., 2. Feb. 2026CallReview
3 events on Di., 3. Feb. 2026ReviewLab
4 events on Mi., 4. Feb. 2026LabShift
5 events on Do., 5. Feb. 2026ShiftCheck-up
3 events on Mo., 9. Feb. 2026ReviewLab
4 events on Di., 10. Feb. 2026LabShift
5 events on Mi., 11. Feb. 2026ShiftCheck-up
1 event on Fr., 13. Feb. 2026Visit
4 events on Mo., 16. Feb. 2026LabShift
5 events on Di., 17. Feb. 2026ShiftCheck-up
1 event on Do., 19. Feb. 2026Visit
2 events on Fr., 20. Feb. 2026CallReview
5 events on Mo., 23. Feb. 2026ShiftCheck-up
1 event on Mi., 25. Feb. 2026Visit
2 events on Do., 26. Feb. 2026CallReview
3 events on Fr., 27. Feb. 2026ReviewLab

März

3 events on Mo., 2. März 2026Check-upVisit
4 events on Di., 3. März 2026VisitCall
5 events on Mi., 4. März 2026CallReview
1 event on Fr., 6. März 2026Lab
4 events on Mo., 9. März 2026VisitCall
5 events on Di., 10. März 2026CallReview
1 event on Do., 12. März 2026Lab
2 events on Fr., 13. März 2026ShiftCheck-up
5 events on Mo., 16. März 2026CallReview
1 event on Mi., 18. März 2026Lab
2 events on Do., 19. März 2026ShiftCheck-up
3 events on Fr., 20. März 2026Check-upVisit
1 event on Di., 24. März 2026Lab
2 events on Mi., 25. März 2026ShiftCheck-up
3 events on Do., 26. März 2026Check-upVisit
4 events on Fr., 27. März 2026VisitCall
1 event on Mo., 30. März 2026Lab
2 events on Di., 31. März 2026ShiftCheck-up

April

1 event on Do., 2. Apr. 2026Visit
2 events on Fr., 3. Apr. 2026CallReview
5 events on Mo., 6. Apr. 2026ShiftCheck-up
1 event on Mi., 8. Apr. 2026Visit
2 events on Do., 9. Apr. 2026CallReview
3 events on Fr., 10. Apr. 2026ReviewLab
1 event on Di., 14. Apr. 2026Visit
2 events on Mi., 15. Apr. 2026CallReview
3 events on Do., 16. Apr. 2026ReviewLab
4 events on Fr., 17. Apr. 2026LabShift
1 event on Mo., 20. Apr. 2026Visit
2 events on Di., 21. Apr. 2026CallReview
3 events on Mi., 22. Apr. 2026ReviewLab
4 events on Do., 23. Apr. 2026LabShift
5 events on Fr., 24. Apr. 2026ShiftCheck-up
2 events on Mo., 27. Apr. 2026CallReview
3 events on Di., 28. Apr. 2026ReviewLab
4 events on Mi., 29. Apr. 2026LabShift
5 events on Do., 30. Apr. 2026ShiftCheck-up

Mai

3 events on Fr., 1. Mai 2026Check-upVisit
1 event on Di., 5. Mai 2026Lab
2 events on Mi., 6. Mai 2026ShiftCheck-up
3 events on Do., 7. Mai 2026Check-upVisit
4 events on Fr., 8. Mai 2026VisitCall
1 event on Mo., 11. Mai 2026Lab
2 events on Di., 12. Mai 2026ShiftCheck-up
3 events on Mi., 13. Mai 2026Check-upVisit
4 events on Do., 14. Mai 2026VisitCall
5 events on Fr., 15. Mai 2026CallReview
2 events on Mo., 18. Mai 2026ShiftCheck-up
3 events on Di., 19. Mai 2026Check-upVisit
4 events on Mi., 20. Mai 2026VisitCall
5 events on Do., 21. Mai 2026CallReview
3 events on Mo., 25. Mai 2026Check-upVisit
4 events on Di., 26. Mai 2026VisitCall
5 events on Mi., 27. Mai 2026CallReview
1 event on Fr., 29. Mai 2026Lab

Juni

1 event on Mo., 1. Juni 2026Visit
2 events on Di., 2. Juni 2026CallReview
3 events on Mi., 3. Juni 2026ReviewLab
4 events on Do., 4. Juni 2026LabShift
5 events on Fr., 5. Juni 2026ShiftCheck-up
2 events on Mo., 8. Juni 2026CallReview
3 events on Di., 9. Juni 2026ReviewLab
4 events on Mi., 10. Juni 2026LabShift
5 events on Do., 11. Juni 2026ShiftCheck-up
3 events on Mo., 15. Juni 2026ReviewLab
4 events on Di., 16. Juni 2026LabShift
5 events on Mi., 17. Juni 2026ShiftCheck-up
1 event on Fr., 19. Juni 2026Visit
4 events on Mo., 22. Juni 2026LabShift
5 events on Di., 23. Juni 2026ShiftCheck-up
1 event on Do., 25. Juni 2026Visit
2 events on Fr., 26. Juni 2026CallReview
5 events on Mo., 29. Juni 2026ShiftCheck-up

Juli

4 events on Mi., 1. Juli 2026VisitCall
5 events on Do., 2. Juli 2026CallReview
3 events on Mo., 6. Juli 2026Check-upVisit
4 events on Di., 7. Juli 2026VisitCall
5 events on Mi., 8. Juli 2026CallReview
1 event on Fr., 10. Juli 2026Lab
4 events on Mo., 13. Juli 2026VisitCall
5 events on Di., 14. Juli 2026CallReview
1 event on Do., 16. Juli 2026Lab
2 events on Fr., 17. Juli 2026ShiftCheck-up
5 events on Mo., 20. Juli 2026CallReview
1 event on Mi., 22. Juli 2026Lab
2 events on Do., 23. Juli 2026ShiftCheck-up
3 events on Fr., 24. Juli 2026Check-upVisit
1 event on Di., 28. Juli 2026Lab
2 events on Mi., 29. Juli 2026ShiftCheck-up
3 events on Do., 30. Juli 2026Check-upVisit
4 events on Fr., 31. Juli 2026VisitCall

August

September

1 event on Mi., 2. Sept. 2026Lab
2 events on Do., 3. Sept. 2026ShiftCheck-up
3 events on Fr., 4. Sept. 2026Check-upVisit
1 event on Di., 8. Sept. 2026Lab
2 events on Mi., 9. Sept. 2026ShiftCheck-up
3 events on Do., 10. Sept. 2026Check-upVisit
4 events on Fr., 11. Sept. 2026VisitCall
1 event on Mo., 14. Sept. 2026Lab
2 events on Di., 15. Sept. 2026ShiftCheck-up
3 events on Mi., 16. Sept. 2026Check-upVisit
4 events on Do., 17. Sept. 2026VisitCall
5 events on Fr., 18. Sept. 2026CallReview
2 events on Mo., 21. Sept. 2026ShiftCheck-up
3 events on Di., 22. Sept. 2026Check-upVisit
4 events on Mi., 23. Sept. 2026VisitCall
5 events on Do., 24. Sept. 2026CallReview
3 events on Mo., 28. Sept. 2026Check-upVisit
4 events on Di., 29. Sept. 2026VisitCall
5 events on Mi., 30. Sept. 2026CallReview

Oktober

3 events on Do., 1. Okt. 2026ReviewLab
4 events on Fr., 2. Okt. 2026LabShift
1 event on Mo., 5. Okt. 2026Visit
2 events on Di., 6. Okt. 2026CallReview
3 events on Mi., 7. Okt. 2026ReviewLab
4 events on Do., 8. Okt. 2026LabShift
5 events on Fr., 9. Okt. 2026ShiftCheck-up
2 events on Mo., 12. Okt. 2026CallReview
3 events on Di., 13. Okt. 2026ReviewLab
4 events on Mi., 14. Okt. 2026LabShift
5 events on Do., 15. Okt. 2026ShiftCheck-up
3 events on Mo., 19. Okt. 2026ReviewLab
4 events on Di., 20. Okt. 2026LabShift
5 events on Mi., 21. Okt. 2026ShiftCheck-up
1 event on Fr., 23. Okt. 2026Visit
4 events on Mo., 26. Okt. 2026LabShift
5 events on Di., 27. Okt. 2026ShiftCheck-up
1 event on Do., 29. Okt. 2026Visit
2 events on Fr., 30. Okt. 2026CallReview

November

2 events on Mo., 2. Nov. 2026ShiftCheck-up
3 events on Di., 3. Nov. 2026Check-upVisit
4 events on Mi., 4. Nov. 2026VisitCall
5 events on Do., 5. Nov. 2026CallReview
3 events on Mo., 9. Nov. 2026Check-upVisit
4 events on Di., 10. Nov. 2026VisitCall
5 events on Mi., 11. Nov. 2026CallReview
1 event on Fr., 13. Nov. 2026Lab
4 events on Mo., 16. Nov. 2026VisitCall
5 events on Di., 17. Nov. 2026CallReview
1 event on Do., 19. Nov. 2026Lab
2 events on Fr., 20. Nov. 2026ShiftCheck-up
5 events on Mo., 23. Nov. 2026CallReview
1 event on Mi., 25. Nov. 2026Lab
2 events on Do., 26. Nov. 2026ShiftCheck-up
3 events on Fr., 27. Nov. 2026Check-upVisit

Dezember

4 events on Di., 1. Dez. 2026LabShift
5 events on Mi., 2. Dez. 2026ShiftCheck-up
1 event on Fr., 4. Dez. 2026Visit
4 events on Mo., 7. Dez. 2026LabShift
5 events on Di., 8. Dez. 2026ShiftCheck-up
1 event on Do., 10. Dez. 2026Visit
2 events on Fr., 11. Dez. 2026CallReview
5 events on Mo., 14. Dez. 2026ShiftCheck-up
1 event on Mi., 16. Dez. 2026Visit
2 events on Do., 17. Dez. 2026CallReview
3 events on Fr., 18. Dez. 2026ReviewLab
1 event on Di., 22. Dez. 2026Visit
2 events on Mi., 23. Dez. 2026CallReview
3 events on Do., 24. Dez. 2026ReviewLab
4 events on Fr., 25. Dez. 2026LabShift
1 event on Mo., 28. Dez. 2026Visit
2 events on Di., 29. Dez. 2026CallReview
3 events on Mi., 30. Dez. 2026ReviewLab
4 events on Do., 31. Dez. 2026LabShift

Press a day or a month.

Empty year

Nothing on any day: every cell is inert, and the legend drops its "up to" because there is no scale to state.

Januar

Februar

März

April

Mai

Juni

Juli

August

September

Oktober

November

Dezember

FewerMore

Aggregate counts

counts takes a { "YYYY-MM-DD": n } record — what a server returns when the year is an aggregate, such as commits, rather than a list of events. The label and the legend say what is being counted.

Januar

1 commit on Sa., 3. Jan. 20262 commits on So., 4. Jan. 20263 commits on Mo., 5. Jan. 20264 commits on Di., 6. Jan. 20265 commits on Mi., 7. Jan. 20266 commits on Do., 8. Jan. 20267 commits on Fr., 9. Jan. 20268 commits on Sa., 10. Jan. 20269 commits on So., 11. Jan. 202610 commits on Mo., 12. Jan. 20261 commit on Di., 20. Jan. 20262 commits on Mi., 21. Jan. 20263 commits on Do., 22. Jan. 20264 commits on Fr., 23. Jan. 20265 commits on Sa., 24. Jan. 20266 commits on So., 25. Jan. 20267 commits on Mo., 26. Jan. 20268 commits on Di., 27. Jan. 20269 commits on Mi., 28. Jan. 202610 commits on Do., 29. Jan. 2026

Februar

1 commit on So., 8. Feb. 20262 commits on Mo., 9. Feb. 20263 commits on Di., 10. Feb. 20264 commits on Mi., 11. Feb. 20265 commits on Do., 12. Feb. 20266 commits on Fr., 13. Feb. 20267 commits on Sa., 14. Feb. 20268 commits on So., 15. Feb. 20269 commits on Mo., 16. Feb. 202610 commits on Di., 17. Feb. 20261 commit on Mi., 25. Feb. 20262 commits on Do., 26. Feb. 20263 commits on Fr., 27. Feb. 20264 commits on Sa., 28. Feb. 2026

März

1 commit on So., 1. März 20262 commits on Mo., 2. März 20263 commits on Di., 3. März 20264 commits on Mi., 4. März 20265 commits on Do., 5. März 20266 commits on Fr., 6. März 20267 commits on Sa., 7. März 20268 commits on So., 8. März 20269 commits on Mo., 9. März 202610 commits on Di., 10. März 20261 commit on Mi., 18. März 20262 commits on Do., 19. März 20263 commits on Fr., 20. März 20264 commits on Sa., 21. März 20265 commits on So., 22. März 20266 commits on Mo., 23. März 20267 commits on Di., 24. März 20268 commits on Mi., 25. März 20269 commits on Do., 26. März 202610 commits on Fr., 27. März 2026

April

1 commit on Mo., 6. Apr. 20262 commits on Di., 7. Apr. 20263 commits on Mi., 8. Apr. 20264 commits on Do., 9. Apr. 20265 commits on Fr., 10. Apr. 20266 commits on Sa., 11. Apr. 20267 commits on So., 12. Apr. 20268 commits on Mo., 13. Apr. 20269 commits on Di., 14. Apr. 202610 commits on Mi., 15. Apr. 20261 commit on Do., 23. Apr. 20262 commits on Fr., 24. Apr. 20263 commits on Sa., 25. Apr. 20264 commits on So., 26. Apr. 20265 commits on Mo., 27. Apr. 20266 commits on Di., 28. Apr. 20267 commits on Mi., 29. Apr. 20268 commits on Do., 30. Apr. 2026

Mai

1 commit on Do., 7. Mai 20262 commits on Fr., 8. Mai 20263 commits on Sa., 9. Mai 20264 commits on So., 10. Mai 20265 commits on Mo., 11. Mai 20266 commits on Di., 12. Mai 20267 commits on Mi., 13. Mai 20268 commits on Do., 14. Mai 20269 commits on Fr., 15. Mai 202610 commits on Sa., 16. Mai 20261 commit on So., 24. Mai 20262 commits on Mo., 25. Mai 20263 commits on Di., 26. Mai 20264 commits on Mi., 27. Mai 20265 commits on Do., 28. Mai 20266 commits on Fr., 29. Mai 20267 commits on Sa., 30. Mai 20268 commits on So., 31. Mai 2026

Juni

7 commits on Mo., 1. Juni 20268 commits on Di., 2. Juni 20269 commits on Mi., 3. Juni 202610 commits on Do., 4. Juni 20261 commit on Fr., 12. Juni 20262 commits on Sa., 13. Juni 20263 commits on So., 14. Juni 20264 commits on Mo., 15. Juni 20265 commits on Di., 16. Juni 20266 commits on Mi., 17. Juni 20267 commits on Do., 18. Juni 20268 commits on Fr., 19. Juni 20269 commits on Sa., 20. Juni 202610 commits on So., 21. Juni 20261 commit on Mo., 29. Juni 20262 commits on Di., 30. Juni 2026

Juli

6 commits on Mi., 1. Juli 20267 commits on Do., 2. Juli 20268 commits on Fr., 3. Juli 20269 commits on Sa., 4. Juli 202610 commits on So., 5. Juli 20261 commit on Mo., 13. Juli 20262 commits on Di., 14. Juli 20263 commits on Mi., 15. Juli 20264 commits on Do., 16. Juli 20265 commits on Fr., 17. Juli 20266 commits on Sa., 18. Juli 20267 commits on So., 19. Juli 20268 commits on Mo., 20. Juli 20269 commits on Di., 21. Juli 202610 commits on Mi., 22. Juli 20261 commit on Do., 30. Juli 20262 commits on Fr., 31. Juli 2026

August

1 commit on Sa., 1. Aug. 20262 commits on So., 2. Aug. 20263 commits on Mo., 3. Aug. 20264 commits on Di., 4. Aug. 20265 commits on Mi., 5. Aug. 20266 commits on Do., 6. Aug. 20267 commits on Fr., 7. Aug. 20268 commits on Sa., 8. Aug. 20269 commits on So., 9. Aug. 202610 commits on Mo., 10. Aug. 20261 commit on Di., 18. Aug. 20262 commits on Mi., 19. Aug. 20263 commits on Do., 20. Aug. 20264 commits on Fr., 21. Aug. 20265 commits on Sa., 22. Aug. 20266 commits on So., 23. Aug. 20267 commits on Mo., 24. Aug. 20268 commits on Di., 25. Aug. 20269 commits on Mi., 26. Aug. 202610 commits on Do., 27. Aug. 2026

September

1 commit on So., 6. Sept. 20262 commits on Mo., 7. Sept. 20263 commits on Di., 8. Sept. 20264 commits on Mi., 9. Sept. 20265 commits on Do., 10. Sept. 20266 commits on Fr., 11. Sept. 20267 commits on Sa., 12. Sept. 20268 commits on So., 13. Sept. 20269 commits on Mo., 14. Sept. 202610 commits on Di., 15. Sept. 20261 commit on Mi., 23. Sept. 20262 commits on Do., 24. Sept. 20263 commits on Fr., 25. Sept. 20264 commits on Sa., 26. Sept. 20265 commits on So., 27. Sept. 20266 commits on Mo., 28. Sept. 20267 commits on Di., 29. Sept. 20268 commits on Mi., 30. Sept. 2026

Oktober

1 commit on Mi., 7. Okt. 20262 commits on Do., 8. Okt. 20263 commits on Fr., 9. Okt. 20264 commits on Sa., 10. Okt. 20265 commits on So., 11. Okt. 20266 commits on Mo., 12. Okt. 20267 commits on Di., 13. Okt. 20268 commits on Mi., 14. Okt. 20269 commits on Do., 15. Okt. 202610 commits on Fr., 16. Okt. 20261 commit on Sa., 24. Okt. 20262 commits on So., 25. Okt. 20263 commits on Mo., 26. Okt. 20264 commits on Di., 27. Okt. 20265 commits on Mi., 28. Okt. 20266 commits on Do., 29. Okt. 20267 commits on Fr., 30. Okt. 20268 commits on Sa., 31. Okt. 2026

November

7 commits on So., 1. Nov. 20268 commits on Mo., 2. Nov. 20269 commits on Di., 3. Nov. 202610 commits on Mi., 4. Nov. 20261 commit on Do., 12. Nov. 20262 commits on Fr., 13. Nov. 20263 commits on Sa., 14. Nov. 20264 commits on So., 15. Nov. 20265 commits on Mo., 16. Nov. 20266 commits on Di., 17. Nov. 20267 commits on Mi., 18. Nov. 20268 commits on Do., 19. Nov. 20269 commits on Fr., 20. Nov. 202610 commits on Sa., 21. Nov. 20261 commit on So., 29. Nov. 20262 commits on Mo., 30. Nov. 2026

Dezember

6 commits on Di., 1. Dez. 20267 commits on Mi., 2. Dez. 20268 commits on Do., 3. Dez. 20269 commits on Fr., 4. Dez. 202610 commits on Sa., 5. Dez. 20261 commit on So., 13. Dez. 20262 commits on Mo., 14. Dez. 20263 commits on Di., 15. Dez. 20264 commits on Mi., 16. Dez. 20265 commits on Do., 17. Dez. 20266 commits on Fr., 18. Dez. 20267 commits on Sa., 19. Dez. 20268 commits on So., 20. Dez. 20269 commits on Mo., 21. Dez. 202610 commits on Di., 22. Dez. 20261 commit on Mi., 30. Dez. 20262 commits on Do., 31. Dez. 2026
FewerMore(up to 10 commits in a day)

Sunday-first locale

locale="en-US": the weekday initials, the column the 1st lands in and every date the cells are named with follow it. firstDayOfWeek overrides the first column alone.

January

3 events on Thu, Jan 1, 20264 events on Fri, Jan 2, 20261 event on Mon, Jan 5, 20262 events on Tue, Jan 6, 20263 events on Wed, Jan 7, 20264 events on Thu, Jan 8, 20265 events on Fri, Jan 9, 20262 events on Mon, Jan 12, 20263 events on Tue, Jan 13, 20264 events on Wed, Jan 14, 20265 events on Thu, Jan 15, 20263 events on Mon, Jan 19, 20264 events on Tue, Jan 20, 20265 events on Wed, Jan 21, 20261 event on Fri, Jan 23, 20264 events on Mon, Jan 26, 20265 events on Tue, Jan 27, 20261 event on Thu, Jan 29, 20262 events on Fri, Jan 30, 2026

February

2 events on Mon, Feb 2, 20263 events on Tue, Feb 3, 20264 events on Wed, Feb 4, 20265 events on Thu, Feb 5, 20263 events on Mon, Feb 9, 20264 events on Tue, Feb 10, 20265 events on Wed, Feb 11, 20261 event on Fri, Feb 13, 20264 events on Mon, Feb 16, 20265 events on Tue, Feb 17, 20261 event on Thu, Feb 19, 20262 events on Fri, Feb 20, 20265 events on Mon, Feb 23, 20261 event on Wed, Feb 25, 20262 events on Thu, Feb 26, 20263 events on Fri, Feb 27, 2026

March

3 events on Mon, Mar 2, 20264 events on Tue, Mar 3, 20265 events on Wed, Mar 4, 20261 event on Fri, Mar 6, 20264 events on Mon, Mar 9, 20265 events on Tue, Mar 10, 20261 event on Thu, Mar 12, 20262 events on Fri, Mar 13, 20265 events on Mon, Mar 16, 20261 event on Wed, Mar 18, 20262 events on Thu, Mar 19, 20263 events on Fri, Mar 20, 20261 event on Tue, Mar 24, 20262 events on Wed, Mar 25, 20263 events on Thu, Mar 26, 20264 events on Fri, Mar 27, 20261 event on Mon, Mar 30, 20262 events on Tue, Mar 31, 2026

April

1 event on Thu, Apr 2, 20262 events on Fri, Apr 3, 20265 events on Mon, Apr 6, 20261 event on Wed, Apr 8, 20262 events on Thu, Apr 9, 20263 events on Fri, Apr 10, 20261 event on Tue, Apr 14, 20262 events on Wed, Apr 15, 20263 events on Thu, Apr 16, 20264 events on Fri, Apr 17, 20261 event on Mon, Apr 20, 20262 events on Tue, Apr 21, 20263 events on Wed, Apr 22, 20264 events on Thu, Apr 23, 20265 events on Fri, Apr 24, 20262 events on Mon, Apr 27, 20263 events on Tue, Apr 28, 20264 events on Wed, Apr 29, 20265 events on Thu, Apr 30, 2026

May

3 events on Fri, May 1, 20261 event on Tue, May 5, 20262 events on Wed, May 6, 20263 events on Thu, May 7, 20264 events on Fri, May 8, 20261 event on Mon, May 11, 20262 events on Tue, May 12, 20263 events on Wed, May 13, 20264 events on Thu, May 14, 20265 events on Fri, May 15, 20262 events on Mon, May 18, 20263 events on Tue, May 19, 20264 events on Wed, May 20, 20265 events on Thu, May 21, 20263 events on Mon, May 25, 20264 events on Tue, May 26, 20265 events on Wed, May 27, 20261 event on Fri, May 29, 2026

June

1 event on Mon, Jun 1, 20262 events on Tue, Jun 2, 20263 events on Wed, Jun 3, 20264 events on Thu, Jun 4, 20265 events on Fri, Jun 5, 20262 events on Mon, Jun 8, 20263 events on Tue, Jun 9, 20264 events on Wed, Jun 10, 20265 events on Thu, Jun 11, 20263 events on Mon, Jun 15, 20264 events on Tue, Jun 16, 20265 events on Wed, Jun 17, 20261 event on Fri, Jun 19, 20264 events on Mon, Jun 22, 20265 events on Tue, Jun 23, 20261 event on Thu, Jun 25, 20262 events on Fri, Jun 26, 20265 events on Mon, Jun 29, 2026

July

4 events on Wed, Jul 1, 20265 events on Thu, Jul 2, 20263 events on Mon, Jul 6, 20264 events on Tue, Jul 7, 20265 events on Wed, Jul 8, 20261 event on Fri, Jul 10, 20264 events on Mon, Jul 13, 20265 events on Tue, Jul 14, 20261 event on Thu, Jul 16, 20262 events on Fri, Jul 17, 20265 events on Mon, Jul 20, 20261 event on Wed, Jul 22, 20262 events on Thu, Jul 23, 20263 events on Fri, Jul 24, 20261 event on Tue, Jul 28, 20262 events on Wed, Jul 29, 20263 events on Thu, Jul 30, 20264 events on Fri, Jul 31, 2026

August

September

1 event on Wed, Sep 2, 20262 events on Thu, Sep 3, 20263 events on Fri, Sep 4, 20261 event on Tue, Sep 8, 20262 events on Wed, Sep 9, 20263 events on Thu, Sep 10, 20264 events on Fri, Sep 11, 20261 event on Mon, Sep 14, 20262 events on Tue, Sep 15, 20263 events on Wed, Sep 16, 20264 events on Thu, Sep 17, 20265 events on Fri, Sep 18, 20262 events on Mon, Sep 21, 20263 events on Tue, Sep 22, 20264 events on Wed, Sep 23, 20265 events on Thu, Sep 24, 20263 events on Mon, Sep 28, 20264 events on Tue, Sep 29, 20265 events on Wed, Sep 30, 2026

October

3 events on Thu, Oct 1, 20264 events on Fri, Oct 2, 20261 event on Mon, Oct 5, 20262 events on Tue, Oct 6, 20263 events on Wed, Oct 7, 20264 events on Thu, Oct 8, 20265 events on Fri, Oct 9, 20262 events on Mon, Oct 12, 20263 events on Tue, Oct 13, 20264 events on Wed, Oct 14, 20265 events on Thu, Oct 15, 20263 events on Mon, Oct 19, 20264 events on Tue, Oct 20, 20265 events on Wed, Oct 21, 20261 event on Fri, Oct 23, 20264 events on Mon, Oct 26, 20265 events on Tue, Oct 27, 20261 event on Thu, Oct 29, 20262 events on Fri, Oct 30, 2026

November

2 events on Mon, Nov 2, 20263 events on Tue, Nov 3, 20264 events on Wed, Nov 4, 20265 events on Thu, Nov 5, 20263 events on Mon, Nov 9, 20264 events on Tue, Nov 10, 20265 events on Wed, Nov 11, 20261 event on Fri, Nov 13, 20264 events on Mon, Nov 16, 20265 events on Tue, Nov 17, 20261 event on Thu, Nov 19, 20262 events on Fri, Nov 20, 20265 events on Mon, Nov 23, 20261 event on Wed, Nov 25, 20262 events on Thu, Nov 26, 20263 events on Fri, Nov 27, 2026

December

4 events on Tue, Dec 1, 20265 events on Wed, Dec 2, 20261 event on Fri, Dec 4, 20264 events on Mon, Dec 7, 20265 events on Tue, Dec 8, 20261 event on Thu, Dec 10, 20262 events on Fri, Dec 11, 20265 events on Mon, Dec 14, 20261 event on Wed, Dec 16, 20262 events on Thu, Dec 17, 20263 events on Fri, Dec 18, 20261 event on Tue, Dec 22, 20262 events on Wed, Dec 23, 20263 events on Thu, Dec 24, 20264 events on Fri, Dec 25, 20261 event on Mon, Dec 28, 20262 events on Tue, Dec 29, 20263 events on Wed, Dec 30, 20264 events on Thu, Dec 31, 2026

The number on a day is how many events it has.

source

year-view.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import {
  type CalendarDate,
  endOfYear,
  getDayOfWeek,
  startOfYear,
  toCalendarDate,
  toTimeZone,
} from "@internationalized/date"
import { useId, useMemo } from "react"
import {
  CALENDAR_COLORS,
  type CalendarEvent,
  type CalendarSource,
  DayOverflowPanel,
  dayToDate,
  DEFAULT_CALENDAR_TIME_ZONE,
  MoreLink,
  resolveEventColor,
  useCalendarToday,
} from "@/components/calendar-shell"
import {
  type CalendarToolbarLabelVariant,
  CalendarToolbar,
  type CalendarViewName,
  type CalendarViewOption,
  calendarYearLabel,
  useCalendarLocale,
  useCalendarNavigation,
} from "@/components/calendar-toolbar"
import { Card } from "@/components/card"
import { Heading } from "@/components/heading"
import { Link } from "@/components/link"
import { isAllDayEvent, weekRange } from "@/lib/calendar"
import { getDateTimeFormat, getNumberFormat } from "@/lib/intl"
import { cn } from "@/lib/utils"

/**
 * YearView — quebi design system
 *
 * A whole year as twelve small month cards: the view a reader opens to see
 * *when* things happen across a year rather than *what* happens on a day. Every
 * day with something on it is a link to that day, every month heading a link to
 * that month, and a day with nothing on it is inert — there is nowhere useful
 * for it to go.
 *
 * ## Three variants, one grid
 *
 * The cards, the weekday row, the offset of the 1st and the today ring are the
 * same in all three; what a day cell *says* is the variant.
 *
 * - `heatmap` — the cell's fill steps with the day's count. The pattern is the
 *   point: busy weeks, empty holidays, the month the project shipped. A legend
 *   under the grid keys the five steps.
 * - `count` — a neutral cell with the date and the exact number. For when "was
 *   it 3 or 4" matters more than the shape of the year, and for anyone who
 *   cannot tell five greys apart. No colour scale, so no swatch legend — a
 *   caption says what the number counts.
 * - `list` — bigger cells that name the day's first few events and fold the
 *   rest into the same "+N more" `MonthView` draws, opening the same panel.
 *   Fewer cards per row, because a title needs width.
 *
 * ## Where the numbers come from
 *
 * `events` and `calendars` are the ones every other view here takes, so one
 * list feeds the year, the month and the day. A day's count is the number of
 * events that touch it in `timeZone` — a night shift counts on both days.
 *
 * `counts` is the other feed: `{ "2026-09-07": 3 }`, keyed by ISO date. It is
 * what a server returns when the year is an aggregate — commits, sign-ups,
 * sessions — and loading a year of events to count them in the browser would be
 * the wrong shape of query. It is a plain record rather than a function because
 * it is what a route loader can serialize, and this site prerenders. Given
 * `counts`, the heatmap and count variants read it and ignore `events` for
 * counting. `list` names events, and a count cannot be named, so it always
 * reads `events`.
 *
 * ## The heatmap's steps
 *
 * Four steps above empty, and the rule is written out because the obvious one is
 * wrong. Scaling each count against the year's busiest day makes that day full
 * ink whatever it holds — a year whose busiest day has one event would paint
 * every event day full, which reads as "every day was as busy as it gets". So
 * the scale never runs below four: one step is at least one event, and the full
 * fill is reserved for a day that is busy in absolute terms as well as relative
 * ones. See `heatmapLevel`. Pass `maxCount` to pin the scale — two years side by
 * side are only comparable if they share one.
 *
 * ## Links, not routes
 *
 * The component knows no URLs. `dayHref` / `monthHref` return one (rendered as a
 * `Link`, so a `RouterProvider` makes it a client navigation), `onDayAction` /
 * `onMonthAction` are the alternative for a page that switches view in state.
 * With neither, a day is text with its count said in words, and a month heading
 * is a heading.
 *
 * ## Layout follows the view, not the window
 *
 * The number of cards per row is a container query on the view's own width, so
 * the year reads the same in a full page, a dashboard column and the gallery.
 *
 * ## The year
 *
 * `date` / `defaultDate` / `onDateChange` — any day in the year on show — the
 * pair `MonthView` and the others take, so a page that switches between views
 * holds one date rather than a date and a year. The toolbar (on by default)
 * steps a year at a time and opens a `YearPicker` from its heading. Today is
 * read after mount, never at render; pass `now` to pin it.
 */

export type YearViewVariant = "heatmap" | "count" | "list"

/** How many fills a heatmap day can take above empty. */
export const HEATMAP_LEVELS = 4

/**
 * The heatmap step, 0–4, for `count` events on a scale whose busiest day is `max`.
 *
 * 0 is empty and only empty. Above that, `ceil(count × 4 / scale)` where the
 * scale is `max` but never less than four, so each step is at least one event:
 *
 * | busiest day | 1 | 2 | 3 | 4 |
 * |---|---|---|---|---|
 * | 1 | 1 | | | |
 * | 2 | 1 | 2 | | |
 * | 4 | 1 | 2 | 3 | 4 |
 * | 8 | 1 (1–2) | 2 (3–4) | 3 (5–6) | 4 (7–8) |
 *
 * A count above `max` (a pinned `maxCount` the data outgrew) is the top step.
 */
export function heatmapLevel(count: number, max: number): number {
  if (!(count > 0)) return 0
  const scale = Math.max(max, HEATMAP_LEVELS)
  return Math.min(HEATMAP_LEVELS, Math.max(1, Math.ceil((count * HEATMAP_LEVELS) / scale)))
}

/**
 * The five fills, empty first. Ink at an alpha rather than five tokens: a
 * heatmap is one ink getting stronger, and the steps are fills, never marks.
 */
export const HEATMAP_FILLS = [
  "bg-quebi-raised",
  "bg-quebi-action/25",
  "bg-quebi-action/45",
  "bg-quebi-action/70",
  "bg-quebi-action",
] as const

/**
 * The two strongest fills carry `on-action` ink; the rest, the page's. Each
 * hover repeats the resting colour because a day is a `Link`, and a link's own
 * hover colour would otherwise land on a fill it was not chosen for.
 */
const HEATMAP_TEXT = [
  "text-quebi-fg-subtle",
  "text-quebi-fg hover:text-quebi-fg",
  "text-quebi-fg hover:text-quebi-fg",
  "text-quebi-on-action hover:text-quebi-on-action",
  "text-quebi-on-action hover:text-quebi-on-action",
] as const

const ALL_MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const

export interface YearViewProps<E extends CalendarEvent = CalendarEvent> {
  /** What a day cell shows. Default `heatmap`. */
  variant?: YearViewVariant
  events?: readonly E[]
  /** Where each event's colour comes from, in the `list` variant. */
  calendars?: readonly CalendarSource[]
  /**
   * Counts per day, keyed `YYYY-MM-DD`, for a year that is an aggregate rather
   * than a list of events. Read by `heatmap` and `count` in place of `events`.
   */
  counts?: Readonly<Record<string, number>>
  /** Pin the heatmap's scale instead of reading it off the year's busiest day. */
  maxCount?: number
  /**
   * Which months to draw, 1–12, in the order given. Default all twelve. A
   * quarter or a school term; the heatmap is still scaled against the whole
   * year, so a month reads the same whichever others are beside it.
   */
  months?: readonly number[]
  /** Any day in the year on show. Controlled. */
  date?: CalendarDate
  /** Any day in the year on show at mount. Defaults to today. */
  defaultDate?: CalendarDate
  onDateChange?: (date: CalendarDate) => void
  timeZone?: string
  /** Formats every date and number here. Defaults to the nearest `I18nProvider`. */
  locale?: string
  /** Override the locale's first day of the week. */
  firstDayOfWeek?: "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat"
  /** Pin today, or `null` to ring no day. Read after mount otherwise. */
  now?: CalendarDate | null
  /** Where a day with something on it links to. */
  dayHref?: (day: CalendarDate) => string | undefined
  /** Called when a day with something on it is pressed, with everything on it. */
  onDayAction?: (day: CalendarDate, events: E[]) => void
  /** Where a month heading links to — the first of that month. */
  monthHref?: (month: CalendarDate) => string | undefined
  onMonthAction?: (month: CalendarDate) => void
  /**
   * A day cell's accessible name. `date` is already formatted —
   * `Mon 7 Sept 2026` — and so should the count be: use `formatCount`.
   */
  dayLabel?: (count: number, date: string, formatCount: (count: number) => string) => string
  /** `list` only: event lines per day before "+N more". Default 2. */
  maxItems?: number
  /** `list` only. Default `+N more`. */
  moreLabel?: (count: number) => string
  /** `list` only: take "+N more" over instead of opening the day's panel. */
  onMoreClick?: (day: CalendarDate, events: E[]) => void
  /** `list` only: an event row in the "+N more" panel was pressed. */
  onEventClick?: (event: E) => void
  /** The legend (`heatmap`) or caption (`count`) under the grid. Default on. */
  showLegend?: boolean
  /** The legend's words — the place to translate them. */
  legendLabels?: {
    fewer?: string
    more?: string
    /** `(up to 4 in a day)` */
    max?: (max: string) => string
    /** The `count` variant's caption. */
    count?: string
  }
  showToolbar?: boolean
  /** The toolbar heading. Defaults to the year, spelled by the locale. */
  label?: React.ReactNode
  labelVariant?: CalendarToolbarLabelVariant
  view?: CalendarViewName
  views?: readonly CalendarViewOption[]
  onViewChange?: (view: CalendarViewName) => void
  previousYearLabel?: string
  nextYearLabel?: string
  className?: string
}

/** Everything touching each day of `year`, keyed `YYYY-MM-DD`, in reading order. */
export function eventsByDay<E extends CalendarEvent>(
  events: readonly E[],
  year: CalendarDate,
  timeZone: string,
): Map<string, E[]> {
  const first = startOfYear(year)
  const last = endOfYear(year)
  const byDay = new Map<string, E[]>()
  for (const event of events) {
    const start = toCalendarDate(toTimeZone(event.start, timeZone))
    const end =
      event.end.compare(event.start) > 0
        ? toCalendarDate(toTimeZone(event.end.subtract({ milliseconds: 1 }), timeZone))
        : start
    if (end.compare(first) < 0 || start.compare(last) > 0) continue
    let day = start.compare(first) < 0 ? first : start
    const stop = end.compare(last) > 0 ? last : end
    for (; day.compare(stop) <= 0; day = day.add({ days: 1 })) {
      const key = day.toString()
      const list = byDay.get(key)
      if (list) list.push(event)
      else byDay.set(key, [event])
    }
  }
  // All-day first, then by start — the order `MonthView`'s panel lists a day in.
  for (const list of byDay.values()) {
    list.sort((a, b) => Number(isAllDayEvent(b)) - Number(isAllDayEvent(a)) || a.start.compare(b.start))
  }
  return byDay
}

/**
 * A year as twelve month cards whose day cells show a heatmap step, a count, or
 * the day's first few events and a "+N more". Days with something on them link
 * to the day, month headings to the month.
 */
export function YearView<E extends CalendarEvent = CalendarEvent>({
  variant = "heatmap",
  events = [],
  calendars,
  counts,
  maxCount,
  months: monthNumbers = ALL_MONTHS,
  date,
  defaultDate,
  onDateChange,
  timeZone = DEFAULT_CALENDAR_TIME_ZONE,
  locale: localeProp,
  firstDayOfWeek,
  now,
  dayHref,
  onDayAction,
  monthHref,
  onMonthAction,
  dayLabel = (count, day, formatCount) =>
    `${formatCount(count)} ${count === 1 ? "event" : "events"} on ${day}`,
  maxItems = 2,
  moreLabel = (count) => `+${count} more`,
  onMoreClick,
  onEventClick,
  showLegend = true,
  legendLabels,
  showToolbar = true,
  label,
  labelVariant = "picker",
  view,
  views,
  onViewChange,
  previousYearLabel = "Previous year",
  nextYearLabel = "Next year",
  className,
}: YearViewProps<E>) {
  const locale = useCalendarLocale(localeProp)
  const today = useCalendarToday(now, timeZone)
  const navigation = useCalendarNavigation({
    date,
    defaultDate,
    onDateChange,
    step: { years: 1 },
    timeZone,
  })
  const year = useMemo(() => startOfYear(navigation.date), [navigation.date])

  const byDay = useMemo(() => eventsByDay(events, year, timeZone), [events, year, timeZone])
  const countOf = (day: CalendarDate) => {
    const key = day.toString()
    if (variant !== "list" && counts) return counts[key] ?? 0
    return byDay.get(key)?.length ?? 0
  }

  // The busiest day of the year on show, from whichever feed is being read.
  const busiest = useMemo(() => {
    if (variant !== "list" && counts) {
      const prefix = `${String(year.year).padStart(4, "0")}-`
      return Object.entries(counts).reduce(
        (max, [key, value]) => (key.startsWith(prefix) && value > max ? value : max),
        0,
      )
    }
    let max = 0
    for (const list of byDay.values()) max = Math.max(max, list.length)
    return max
  }, [variant, counts, byDay, year.year])
  const scale = maxCount ?? busiest

  const numbers = getNumberFormat(locale, {})
  const formatCount = (count: number) => numbers.format(count)
  const dateFormat = getDateTimeFormat(locale, {
    weekday: "short",
    day: "numeric",
    month: "short",
    year: "numeric",
    timeZone,
  })
  const dayNumber = getDateTimeFormat(locale, { day: "numeric", timeZone })
  const monthName = getDateTimeFormat(locale, { month: "long", timeZone })
  const weekdayInitial = getDateTimeFormat(locale, { weekday: "narrow", timeZone })
  const weekdays = weekRange(year, locale, 7, firstDayOfWeek).map((day) =>
    weekdayInitial.format(dayToDate(day, timeZone)),
  )

  const months = monthNumbers
    .filter((month) => Number.isInteger(month) && month >= 1 && month <= 12)
    .map((month) => year.set({ month }))

  return (
    <div
      data-slot="year-view"
      data-variant={variant}
      className={cn("@container/year-view flex w-full flex-col gap-6", className)}
    >
      {showToolbar ? (
        <CalendarToolbar
          label={label ?? calendarYearLabel(year, { locale, timeZone })}
          labelVariant={labelVariant}
          pickerGranularity="year"
          locale={locale}
          firstDayOfWeek={firstDayOfWeek}
          date={navigation.date}
          onDateChange={navigation.goTo}
          view={view}
          views={views}
          onViewChange={onViewChange}
          onPrevious={navigation.goToPrevious}
          onNext={navigation.goToNext}
          onToday={navigation.goToToday}
          isTodayDisabled={today !== null && today.year === year.year}
          previousLabel={previousYearLabel}
          nextLabel={nextYearLabel}
        />
      ) : null}

      <div
        className={cn(
          "grid grid-cols-1 gap-4",
          variant === "list"
            ? "@4xl/year-view:grid-cols-2 @7xl/year-view:grid-cols-3"
            : "@lg/year-view:grid-cols-2 @3xl/year-view:grid-cols-3 @5xl/year-view:grid-cols-4",
        )}
      >
        {months.map((month) => (
          <MonthCard
            key={month.toString()}
            month={month}
            name={monthName.format(dayToDate(month, timeZone))}
            weekdays={weekdays}
            lead={getDayOfWeek(month, locale, firstDayOfWeek)}
            renderDay={(day) => {
              const count = countOf(day)
              const dayEvents = byDay.get(day.toString()) ?? []
              return (
                <DayCell
                  key={day.toString()}
                  day={day}
                  count={count}
                  events={dayEvents}
                  variant={variant}
                  level={heatmapLevel(count, scale)}
                  isToday={today !== null && today.compare(day) === 0}
                  number={dayNumber.format(dayToDate(day, timeZone))}
                  countText={formatCount(count)}
                  label={dayLabel(count, dateFormat.format(dayToDate(day, timeZone)), formatCount)}
                  href={count > 0 ? dayHref?.(day) : undefined}
                  onAction={
                    count > 0 && onDayAction ? () => onDayAction(day, dayEvents) : undefined
                  }
                  calendars={calendars}
                  locale={locale}
                  timeZone={timeZone}
                  maxItems={Math.max(0, Math.trunc(maxItems))}
                  moreLabel={moreLabel}
                  onMoreClick={onMoreClick}
                  onEventClick={onEventClick}
                />
              )
            }}
            href={monthHref?.(month)}
            onAction={onMonthAction ? () => onMonthAction(month) : undefined}
          />
        ))}
      </div>

      {showLegend && variant === "heatmap" ? (
        <HeatmapLegend
          max={formatCount(scale)}
          fewer={legendLabels?.fewer ?? "Fewer"}
          more={legendLabels?.more ?? "More"}
          maxText={legendLabels?.max ?? ((max) => `(up to ${max} in a day)`)}
          hasScale={scale > 0}
        />
      ) : null}
      {showLegend && variant === "count" ? (
        <p data-slot="year-view-legend" className="text-quebi-fg-muted text-xs">
          {legendLabels?.count ?? "The number on a day is how many events it has."}
        </p>
      ) : null}
    </div>
  )
}

interface MonthCardProps {
  month: CalendarDate
  name: string
  weekdays: string[]
  /** Blank cells before the 1st. */
  lead: number
  renderDay: (day: CalendarDate) => React.ReactNode
  href: string | undefined
  onAction: (() => void) | undefined
}

/** One month: a `Card` named by its heading, the weekday row, then the days. */
function MonthCard({ month, name, weekdays, lead, renderDay, href, onAction }: MonthCardProps) {
  const headingId = useId()
  const days = Array.from({ length: month.calendar.getDaysInMonth(month) }, (_, index) =>
    month.add({ days: index }),
  )
  return (
    <Card
      data-slot="year-view-month"
      role="region"
      aria-labelledby={headingId}
      className="h-auto gap-3 p-4"
    >
      <Heading level={3} id={headingId} className="font-display font-light text-quebi-fg text-quebi-nav">
        {href || onAction ? (
          <Link
            {...(href ? { href } : {})}
            onPress={onAction}
            className="text-quebi-fg"
          >
            {name}
          </Link>
        ) : (
          name
        )}
      </Heading>
      <div className="grid grid-cols-7 gap-1">
        {weekdays.map((weekday, index) => (
          <span
            // biome-ignore lint/suspicious/noArrayIndexKey: the column *is* the identity — two narrow weekday names are the same letter in most locales
            key={index}
            aria-hidden="true"
            className="quebi-eyebrow block pb-1 text-center"
          >
            {weekday}
          </span>
        ))}
        {Array.from({ length: lead }, (_, index) => (
          // biome-ignore lint/suspicious/noArrayIndexKey: blank offset cells have no identity but their position
          <span key={`lead-${index}`} aria-hidden="true" />
        ))}
        {days.map(renderDay)}
      </div>
    </Card>
  )
}

interface DayCellProps<E extends CalendarEvent> {
  day: CalendarDate
  count: number
  events: E[]
  variant: YearViewVariant
  level: number
  isToday: boolean
  /** The date as the locale writes a day number. */
  number: string
  countText: string
  label: string
  href: string | undefined
  onAction: (() => void) | undefined
  calendars: readonly CalendarSource[] | undefined
  locale: string
  timeZone: string
  maxItems: number
  moreLabel: (count: number) => string
  onMoreClick: ((day: CalendarDate, events: E[]) => void) | undefined
  onEventClick: ((event: E) => void) | undefined
}

/** Today is an ink outline just outside the cell, so it clears every fill. */
const TODAY_RING = "outline-1 outline-solid outline-offset-1 outline-quebi-fg"

/**
 * A pressable day takes a subtle outline on hover. The focus ring is `Link`'s own; what
 * is taken away is the underline, which on a cell would underline one digit.
 *
 * Every `Link` here spreads `href` only when there is one: a press-only link
 * handed `href={undefined}` still ends up with an empty `href` attribute, which
 * React warns about.
 */
const DAY_LINK =
  "no-underline hover:no-underline hover:outline-1 hover:outline-solid hover:outline-quebi-fg-subtle"

function DayCell<E extends CalendarEvent>(props: DayCellProps<E>) {
  if (props.variant === "list") return <ListDayCell {...props} />

  const { day, count, variant, level, isToday, number, countText, label, href, onAction } = props
  const isHeatmap = variant === "heatmap"
  const surface = cn(
    "flex aspect-square flex-col items-center justify-center gap-0.5 rounded-(--q-radius-mark) text-xs tabular-nums",
    isHeatmap ? cn(HEATMAP_FILLS[level], HEATMAP_TEXT[level]) : "bg-quebi-raised",
    isToday && TODAY_RING,
  )

  if (count === 0) {
    return (
      <span
        data-slot="year-view-day"
        data-date={day.toString()}
        data-current-day={isToday || undefined}
        aria-hidden="true"
        className={cn(surface, !isHeatmap && "text-quebi-fg-subtle")}
      >
        {number}
      </span>
    )
  }

  const content = isHeatmap ? (
    <>
      <span aria-hidden="true">{number}</span>
      <span className="sr-only">{label}</span>
    </>
  ) : (
    <>
      <span aria-hidden="true" className="text-quebi-fg-subtle leading-none">
        {number}
      </span>
      <span aria-hidden="true" className="font-medium text-quebi-fg leading-none">
        {countText}
      </span>
      <span className="sr-only">{label}</span>
    </>
  )

  const shared = {
    "data-slot": "year-view-day",
    "data-date": day.toString(),
    "data-count": count,
    "data-level": isHeatmap ? level : undefined,
    "data-current-day": isToday || undefined,
  }

  if (href || onAction) {
    return (
      <Link
        {...shared}
        {...(href ? { href } : {})}
        onPress={onAction}
        className={cn(surface, !isHeatmap && "text-quebi-fg hover:text-quebi-fg", DAY_LINK)}
      >
        {content}
      </Link>
    )
  }
  return (
    <span {...shared} className={cn(surface, !isHeatmap && "text-quebi-fg")}>
      {content}
    </span>
  )
}

/** A `list` day: the date, the first `maxItems` titles, then "+N more". */
function ListDayCell<E extends CalendarEvent>({
  day,
  count,
  events,
  isToday,
  number,
  label,
  href,
  onAction,
  calendars,
  locale,
  timeZone,
  maxItems,
  moreLabel,
  onMoreClick,
  onEventClick,
}: DayCellProps<E>) {
  const surface = cn(
    "flex min-h-20 min-w-0 flex-col gap-0.5 rounded-(--q-radius-mark) bg-quebi-raised p-1 text-xs",
    isToday && TODAY_RING,
  )

  if (count === 0) {
    return (
      <span
        data-slot="year-view-day"
        data-date={day.toString()}
        data-current-day={isToday || undefined}
        aria-hidden="true"
        className={cn(surface, "text-quebi-fg-subtle tabular-nums")}
      >
        {number}
      </span>
    )
  }

  const shown = events.slice(0, maxItems)
  const hidden = events.length - shown.length
  const dateMark = (
    <>
      <span aria-hidden="true">{number}</span>
      <span className="sr-only">{label}</span>
    </>
  )

  return (
    <div
      data-slot="year-view-day"
      data-date={day.toString()}
      data-count={count}
      data-current-day={isToday || undefined}
      className={surface}
    >
      {href || onAction ? (
        <Link
          {...(href ? { href } : {})}
          onPress={onAction}
          className={cn(
            "self-start px-0.5 font-medium text-quebi-fg tabular-nums hover:text-quebi-fg",
            DAY_LINK,
          )}
        >
          {dateMark}
        </Link>
      ) : (
        <span className="px-0.5 font-medium text-quebi-fg tabular-nums">{dateMark}</span>
      )}
      {shown.map((event) => (
        <span
          key={event.id}
          data-slot="year-view-item"
          className={cn(
            "truncate px-1 text-quebi-fg leading-4",
            CALENDAR_COLORS[resolveEventColor(event, calendars)].band,
          )}
        >
          {event.title}
        </span>
      ))}
      {hidden > 0 ? (
        onMoreClick ? (
          <MoreLink count={hidden} label={moreLabel} onPress={() => onMoreClick(day, events)} />
        ) : (
          <MoreLink count={hidden} label={moreLabel}>
            <DayOverflowPanel
              day={day}
              events={events}
              calendars={calendars}
              locale={locale}
              timeZone={timeZone}
              selectedId={null}
              onActivate={(event) => onEventClick?.(event)}
            />
          </MoreLink>
        )
      ) : null}
    </div>
  )
}

interface HeatmapLegendProps {
  max: string
  fewer: string
  more: string
  maxText: (max: string) => string
  hasScale: boolean
}

/** "Fewer ■■■■■ More (up to N in a day)". The swatches are decoration; the words carry it. */
function HeatmapLegend({ max, fewer, more, maxText, hasScale }: HeatmapLegendProps) {
  return (
    <div
      data-slot="year-view-legend"
      className="flex flex-wrap items-center gap-x-2 gap-y-1 text-quebi-fg-muted text-xs"
    >
      <span>{fewer}</span>
      <span aria-hidden="true" className="flex items-center gap-1">
        {HEATMAP_FILLS.map((fill) => (
          <span key={fill} className={cn("size-3", fill)} />
        ))}
      </span>
      <span>{more}</span>
      {hasScale ? <span className="text-quebi-fg-subtle">{maxText(max)}</span> : null}
    </div>
  )
}