Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**A fast, offline-first academic dashboard for Technion students.**

Semesters · weekly schedule · homework · lecture recordings · Moed A/B exam roadmap · a playful reminders ticker — all in one monochrome, keyboard-friendly workspace that works offline and syncs across your devices when you sign in.
Semesters · weekly schedule · homework · lecture recordings · Moed A/B exam roadmap · a radar that always knows what matters next — all in one monochrome, keyboard-friendly workspace that works offline and syncs across your devices when you sign in.

[**Live at tollab.co.il →**](https://tollab.co.il)

Expand All @@ -26,16 +26,17 @@ Technion coursework is scattered across Cheesefork, the SAP catalog, Panopto, an

## Features

| | |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 📅 **Weekly schedule** | Lane-packed grid with a live "now" line, all-day exam/homework chips, a collapsible view, and a mobile single-day mode. Overnight (past-midnight) classes render correctly. |
| ✅ **Homework** | Per-course and cross-course views, six sort orders, due-date urgency badges, inline notes and links, manual reordering that respects hidden/completed items. |
| 🎓 **Exam roadmap** | A serpentine Moed A/B roadmap that **auto-activates 14 days before your first exam**, with day-gap emphasis, hide/restore, and custom exams. |
| 🎥 **Recordings** | Per-course tabs with inline YouTube/Panopto previews, natural-order sorting, and bulk import from a playlist or Panopto folder. |
| 📥 **One-click import** | Pull your whole schedule from a Cheesefork ICS link (single or batch across semesters); every fetch then auto-enriches courses from the public Technion SAP catalog — existing values are never overwritten. |
| ☁️ **Offline-first + cloud sync** | localStorage is the source of truth; optional Google sign-in syncs all profiles through a single Realtime Database node with a conflict-safe last-write-wins merge. |
| 🌗 **Monochrome by design** | A single-accent light/dark design system built on Tailwind v4 tokens. Color enters only through per-course hues and status signals. Honors `prefers-reduced-motion`. |
| 👥 **Profiles** | Keep multiple degrees/tracks side by side; each syncs independently. |
| | |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 📅 **Weekly schedule** | Lane-packed grid with a live "now" line, all-day exam/homework chips, a collapsible view, and a mobile single-day mode. Overnight (past-midnight) classes render correctly. |
| ✅ **Homework** | Per-course and cross-course views, six sort orders, due-date urgency badges, inline notes and links, manual reordering that respects hidden/completed items. |
| 🎓 **Exam roadmap** | A serpentine Moed A/B roadmap that **auto-activates 14 days before your first exam**, with day-gap emphasis, hide/restore, and custom exams. |
| 📡 **Radar** | A context-aware card that ranks what deserves attention _right now_ — the class running or starting, the most urgent homework, the nearest exam (with a study-pace hint), a recordings backlog — with live countdowns, mark-done and snooze in place, an "up next" rail, and a rotating playful quip. When nothing is urgent it relaxes into time-of-day vibes, progress stats and study tips. |
| 🎥 **Recordings** | Per-course tabs with inline YouTube/Panopto previews, natural-order sorting, and bulk import from a playlist or Panopto folder. |
| 📥 **One-click import** | Pull your whole schedule from a Cheesefork ICS link (single or batch across semesters); every fetch then auto-enriches courses from the public Technion SAP catalog — existing values are never overwritten. |
| ☁️ **Offline-first + cloud sync** | localStorage is the source of truth; optional Google sign-in syncs all profiles through a single Realtime Database node with a conflict-safe last-write-wins merge. |
| 🌗 **Monochrome by design** | A single-accent light/dark design system built on Tailwind v4 tokens. Color enters only through per-course hues and status signals. Honors `prefers-reduced-motion`. |
| 👥 **Profiles** | Keep multiple degrees/tracks side by side; each syncs independently. |

## Architecture

Expand All @@ -45,7 +46,7 @@ A strictly **layered** codebase — the import direction is one-way and **enforc
domain/ Pure logic — no React / zustand / firebase / DOM.
Zod schemas (the single source of truth), colors, semester /
course / homework / recordings rules, calendar grid math,
the exam-mode roadmap, the reminders ticker, and the cloud merge.
the exam-mode roadmap, the radar ranking, and the cloud merge.
services/ Framework-free side effects. storage (v3 codec + export/import),
sync (protocol / engine / backends), firebase adapters, and
Expand All @@ -55,7 +56,7 @@ store/ Zustand stores + a session controller (persistence, profiles)
and the sync host/controller wiring.
features/ React feature slices: courses, calendar, homework, recordings,
exam-mode, ticker, settings, sync, semesters, layout, app.
exam-mode, radar, settings, sync, semesters, layout, app.
components/ The UI kit (Button, Dialog, Toast, Confirm/Prompt, Field, icons…).
hooks/ lib/ Cross-cutting hooks and pure helpers (dates, video embeds, URL safety).
```
Expand Down
2 changes: 1 addition & 1 deletion e2e/courses.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ async function createSemester(page: import('@playwright/test').Page) {
await page.goto('/')
await page.getByRole('button', { name: 'Create your first semester' }).click()
await page.getByRole('button', { name: 'Create Semester' }).click()
// Exact text — the header ticker can also render a "No courses yet" nudge.
// Exact text — the radar card also renders a "No courses yet" nudge.
await expect(page.getByText('No courses yet in this semester.')).toBeVisible()
}

Expand Down
2 changes: 1 addition & 1 deletion e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ test.describe('onboarding', () => {
await page.getByRole('button', { name: 'Create Semester' }).click()

// The semester now exists; the weekly schedule pane appears and courses empty-state shows.
// (Exact text — the header ticker can also render a "No courses yet" nudge.)
// (Exact text — the radar card also renders a "No courses yet" nudge.)
await expect(page.getByRole('heading', { name: 'Weekly Schedule' })).toBeVisible()
await expect(page.getByText('No courses yet in this semester.')).toBeVisible()
})
Expand Down
169 changes: 169 additions & 0 deletions e2e/radar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { test, expect, type Page } from '@playwright/test'

const todayYmd = () => {
const d = new Date()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${mm}-${dd}`
}

const radar = (page: Page) => page.getByTestId('radar')
const headline = (page: Page) => radar(page).getByTestId('radar-title')

/**
* Seeds a v3 profile straight into localStorage: a Monday 10:00–12:00 class
* and an assignment due tomorrow, so a frozen clock at Mon 2026-03-02 11:00
* puts the card in a deterministic "class running now" state.
*/
async function seedLiveClass(page: Page) {
const stamp = '2026-03-02T11:00:00.000Z'
const course = {
id: 'c1',
name: 'Physics',
color: 'hsl(210, 45%, 50%)',
number: '',
points: '',
lecturer: '',
faculty: '',
location: 'Ullman 306',
grade: '',
syllabus: '',
notes: '',
exams: { moedA: '', moedB: '' },
schedule: [{ day: 1, start: '10:00', end: '12:00' }],
homework: [
{ id: 'h1', title: 'Wet 1', dueDate: '2026-03-03', completed: false, notes: '', links: [] },
],
recordings: {
tabs: [
{ id: 'lectures', name: 'Lectures', items: [] },
{ id: 'tutorials', name: 'Tutorials', items: [] },
],
},
homeworkSort: 'date_asc',
recordingsSort: {},
showCompletedHomework: true,
}
const profile = {
v: 3,
savedAt: stamp,
data: {
semesters: [
{
id: 's1',
name: 'Spring 2026',
courses: [course],
calendarSettings: { startHour: 8, endHour: 20, visibleDays: [0, 1, 2, 3, 4, 5] },
examViewMode: 'auto',
hiddenExamIds: [],
customExams: [],
},
],
settings: {
theme: 'light',
colorTheme: 'colorful',
baseColorHue: 200,
showCompleted: false,
showWatchedRecordings: false,
},
lastModified: stamp,
},
}
await page.addInitScript((data) => {
localStorage.setItem('tollab:v3:profiles', JSON.stringify([{ id: 'p1', name: 'Default' }]))
localStorage.setItem('tollab:v3:active', 'p1')
localStorage.setItem('tollab:v3:profile:p1', JSON.stringify(data))
}, profile)
}

test.describe('radar', () => {
test('pins a running class with live progress and promotes an up-next chip', async ({ page }) => {
await page.clock.setFixedTime(new Date(2026, 2, 2, 11, 0))
await seedLiveClass(page)
await page.goto('/')

await expect(radar(page)).toHaveAttribute('data-kind', 'class_now')
await expect(radar(page).getByTestId('radar-badge')).toHaveText('LIVE')
await expect(headline(page)).toHaveText('Physics')
await expect(radar(page)).toContainText('Ullman 306')
await expect(radar(page)).toContainText('10:00–12:00')
await expect(radar(page)).toContainText('ends in 1 h')
await expect(radar(page).getByTestId('radar-progress')).toHaveCSS('width', /px/)
const ratio = await radar(page)
.getByTestId('radar-progress')
.evaluate((el) => el.getBoundingClientRect().width / el.parentElement!.clientWidth)
expect(ratio).toBeGreaterThan(0.45)
expect(ratio).toBeLessThan(0.55)

// The assignment waits in the rail with its fact; promoting it swaps places.
const chip = radar(page).getByRole('button', { name: 'Show Wet 1' })
await expect(chip).toContainText('due tomorrow')
await chip.click()
await expect(headline(page)).toHaveText('Wet 1')
await expect(radar(page)).toHaveAttribute('data-kind', 'hw_tomorrow')
await expect(radar(page).getByRole('button', { name: 'Show Physics' })).toContainText(
'ends in 1 h',
)
})

test('guides setup, surfaces urgent homework, and acts on it in place', async ({ page }) => {
await page.goto('/')
await expect(headline(page)).toHaveText('Start with a semester')

await page.getByRole('button', { name: 'Create your first semester' }).click()
await page.getByRole('button', { name: 'Create Semester' }).click()
await expect(headline(page)).toHaveText('No courses yet')

await page.getByRole('button', { name: 'Add Course' }).click()
await page.getByLabel('Course name').fill('Physics')
await page.getByRole('button', { name: 'Save Course' }).click()
await expect(headline(page)).toHaveText('No class times yet')

// Add an assignment due today through the course dialog.
await page.getByRole('button', { name: /Edit Physics/ }).click()
await page.getByRole('tab', { name: 'Homework' }).click()
await page.getByLabel('Assignment', { exact: true }).fill('Wet 1')
await page.getByLabel('Due date for new assignment').fill(todayYmd())
await page.getByRole('button', { name: 'Add assignment' }).click()
await page.getByRole('button', { name: 'Close' }).click()

// The radar pins it with its facts and offers Done in place.
await expect(headline(page)).toHaveText('Wet 1')
await expect(radar(page)).toContainText('due today')
await expect(radar(page)).toHaveAttribute('data-kind', 'hw_today')
await radar(page).getByRole('button', { name: 'Mark Wet 1 done' }).click()
await expect(headline(page)).toHaveText('No class times yet')
await expect(page.getByRole('status').filter({ hasText: /Done:/ })).toBeVisible()

// Snoozing the last live signal drops the card into calm rotation, and the
// snooze survives a reload (device-local, keyed to today).
await radar(page).getByRole('button', { name: 'Snooze until tomorrow' }).click()
await expect(radar(page)).toHaveAttribute('data-mode', 'calm')
await page.reload()
await expect(radar(page)).toHaveAttribute('data-mode', 'calm')
})

test('opens the course dialog from the headline', async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: 'Create your first semester' }).click()
await page.getByRole('button', { name: 'Create Semester' }).click()
await page.getByRole('button', { name: 'Add Course' }).click()
await page.getByLabel('Course name').fill('Calculus')
await page.getByRole('button', { name: 'Save Course' }).click()
await page.getByRole('button', { name: /Edit Calculus/ }).click()
await page.getByRole('tab', { name: 'Homework' }).click()
await page.getByLabel('Assignment', { exact: true }).fill('Series')
await page.getByLabel('Due date for new assignment').fill(todayYmd())
await page.getByRole('button', { name: 'Add assignment' }).click()
await page.getByRole('button', { name: 'Close' }).click()
await expect(headline(page)).toHaveText('Series')

await radar(page).getByTestId('radar-main').click()
const dialog = page.getByRole('dialog', { name: 'Edit Course' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('tab', { name: 'Homework' })).toHaveAttribute(
'aria-selected',
'true',
)
})
})
17 changes: 4 additions & 13 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { useState } from 'react'
import { resolveExamViewMode } from '@/domain/examMode'
import type { TickerTarget } from '@/domain/ticker'
import { useAppState } from '@/hooks/session'
import { useNow } from '@/hooks/useNow'
import { useMediaQuery } from '@/hooks/useMediaQuery'
import { AppShell } from '@/features/layout/AppShell'
import { Header } from '@/features/layout/Header'
import { HeaderTicker } from '@/features/ticker/HeaderTicker'
import { RadarCard } from '@/features/radar/RadarCard'
import { SemesterControls, AddSemesterDialog } from '@/features/semesters/SemesterControls'
import { SettingsDialog } from '@/features/settings/SettingsDialog'
import { CourseList } from '@/features/courses/CourseList'
import { useCourseDialog } from '@/features/courses/CourseDialogProvider'
import { tickerTargetToRequest } from '@/features/courses/tickerTarget'
import { WeekCalendar } from '@/features/calendar/WeekCalendar'
import { HomeworkList } from '@/features/homework/HomeworkList'
import { ExamRoadmap } from '@/features/exam-mode/ExamRoadmap'
Expand Down Expand Up @@ -58,24 +55,18 @@ function NoSemesterYet() {
}

// LeftPane's heavy child — the course list — does not read the clock, so it does
// not re-render on the minute tick; only the HeaderTicker and the (dormant)
// not re-render on the minute tick; only the RadarCard and the (dormant)
// Add-Semester date picker, both `useNow` consumers, do.
function LeftPane() {
const hasSemester = useAppState((s) => s.currentSemesterId !== null)
const { openCourse } = useCourseDialog()

const onSelect = (target: TickerTarget) => {
const request = tickerTargetToRequest(target)
if (request) openCourse(request)
}

return (
// On desktop the pane fills its height and only the course list scrolls, so
// the header/ticker/semester controls stay put no matter how many courses
// the header/radar/semester controls stay put no matter how many courses
// there are. On mobile (stacked) it's normal flow and the page scrolls.
<div className="flex flex-col gap-6 lg:h-full">
<Header />
<HeaderTicker onSelect={onSelect} />
<RadarCard />
<SemesterControls />
<div className="lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-4 lg:[scrollbar-gutter:stable]">
{hasSemester ? <CourseList /> : <NoSemesterYet />}
Expand Down
Loading
Loading