Skip to content
Open
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
8 changes: 2 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ GOOGLE_CLIENT_SECRET=your_google_client_secret

# NextAuth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your_nextauth_secret # generate with: openssl rand -base64 32
NEXTAUTH_SECRET=your_nextauth_secret # generate with: openssl rand -base64 32

# Firebase Admin (service account JSON, base64-encoded)
# How to encode: base64 -i serviceAccount.json
FIREBASE_ADMIN_CREDENTIALS=your_base64_encoded_service_account_json

# Firebase (client-side)
# Firebase (project ID from Firebase Console → Project Settings)
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
117 changes: 90 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
# 🦑 Kalamari
# 🦑 Kalamari – Tasks variant

Calendar event template manager. Create reusable multi-part event templates and instantly populate your Google Calendar.
> **Branch: `feature/tasks`**
> This branch uses **Google Tasks** instead of Google Calendar events.
> For the Calendar events version, see the [`main`](https://github.com/mortengf/kalamari/tree/main) branch.

Task template manager. Create reusable multi-part task templates and instantly populate your Google Tasks.

## Concept

Many real-world appointments consist of several related calendar events, e.g.:
Many real-world appointments consist of several related tasks, e.g.:

- **Squash / haircut**: "Book!", "The appointment", "Book next"
- **Tickets**: "Buy", "Print", "The event"
- **Birthdays**: "Buy gift", "Send message", "The day"
- **Travel**: "Check in online", "Departure", "Return"
- **Meetings**: "Read material", "The meeting", "Write follow-up"

Kalamari lets you define these as reusable templates and create all related events in one click.
Kalamari lets you define these as reusable templates and create all related tasks in one click, each with a due date calculated relative to an anchor date.

## Tech Stack

- [Next.js 14](https://nextjs.org/) (Pages Router)
- [NextAuth.js](https://next-auth.js.org/) with Google OAuth
- [Google Calendar API](https://developers.google.com/calendar)
- [Firebase Firestore](https://firebase.google.com/docs/firestore) (template + event group storage)
- [Google Tasks API](https://developers.google.com/tasks)
- [Firebase Firestore](https://firebase.google.com/docs/firestore) (template + task group storage)
- [Tailwind CSS](https://tailwindcss.com/)

## Getting Started
Expand All @@ -29,38 +33,98 @@ Kalamari lets you define these as reusable templates and create all related even
```bash
git clone https://github.com/mortengf/kalamari.git
cd kalamari
git checkout feature/tasks
npm install
```

### 2. Set up Google OAuth
### 2. Set up Google OAuth and enable APIs

1. Go to [Google Cloud Console](https://console.cloud.google.com/) and create a new project
2. Go to **APIs & Services → Library** and enable:
- **Google Tasks API**
3. Go to **APIs & Services → Credentials → Create credentials → OAuth 2.0 Client ID**
- Application type: Web application
- Authorized JavaScript origins: `http://localhost:3000`
- Authorized redirect URIs: `http://localhost:3000/api/auth/callback/google`
4. Go to **APIs & Services → OAuth consent screen**
- If the app is in Testing mode, add your Google account under **Test users**

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project
3. Enable the **Google Calendar API**
4. Create **OAuth 2.0 credentials** (Web application)
5. Add `http://localhost:3000/api/auth/callback/google` as an authorized redirect URI
### 3. Set up Firebase Firestore

### 3. Set up Firebase
1. Go to [Firebase Console](https://console.firebase.google.com/) and create a new project
2. Go to **Build → Firestore Database → Create database**
- Choose **Native mode** (not Datastore mode — Datastore mode will not work)
- Choose a region, e.g. `europe-west1`
3. Go to **Project Settings** and note your **Project ID**

1. Create a project in [Firebase Console](https://console.firebase.google.com/)
2. Enable **Firestore** in Native mode
3. Go to Project Settings → Service Accounts → Generate new private key
4. Base64-encode the JSON: `base64 -i serviceAccount.json`
### 4. Set up local Google credentials

### 4. Configure environment
The app uses Application Default Credentials to authenticate with Firestore server-side.

```bash
gcloud auth login
gcloud auth application-default login
gcloud auth application-default set-quota-project YOUR_FIREBASE_PROJECT_ID
```

> **Important:** If you use Google Cloud for other projects, you may have a `GOOGLE_APPLICATION_CREDENTIALS` environment variable set in your shell that points to a different service account. This will override Application Default Credentials and cause authentication errors. Unset it before running the app:
>
> ```bash
> unset GOOGLE_APPLICATION_CREDENTIALS
> ```
>
> You may want to add this to a local `.env` or shell alias for the project.

### 5. Set up IAM permissions

Your Google account needs permission to access Firestore. Go to [Google Cloud Console → IAM](https://console.cloud.google.com/iam-admin/iam), select your Firebase project, and ensure your account has the role:

- **Cloud Datastore User** (`roles/datastore.user`)

### 6. Configure environment

```bash
cp .env.example .env.local
# Fill in all values
```

### 5. Run
Fill in the following values in `.env.local`:

```
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=... # required — generate with: openssl rand -base64 32
NEXT_PUBLIC_FIREBASE_PROJECT_ID=... # your Firebase project ID
```

> **Note:** `NEXTAUTH_SECRET` is required even locally. Without it, NextAuth generates a random secret on each server start, which causes session decryption errors every time you restart `npm run dev`.

### 7. Run

```bash
npm run dev
```

Open [http://localhost:3000](http://localhost:3000).
Open [http://localhost:3000](http://localhost:3000) and sign in with Google.

> **Note:** After signing in for the first time, sign out and sign back in if you encounter Tasks API errors. This ensures the session token includes the correct OAuth scopes.

## Known setup gotchas

**Firestore index required**
The first time you load the Templates page, Firestore may return an error saying a composite index is required. The error message contains a direct link to create the index in Firebase Console — click it and wait ~1 minute for the index to build.

**Wrong Google Cloud project**
If `gcloud config get-value project` shows a different project than your Firebase project, explicitly set the project ID in `.env.local` via `NEXT_PUBLIC_FIREBASE_PROJECT_ID`. The app uses this to connect to the correct Firestore database regardless of your local gcloud config.

## How tasks work

Each template part gets a **due date** calculated by adding its `offsetMinutes` to the anchor date. Since Google Tasks only support due dates (not times), the time component is ignored — only the date matters.

Example with anchor date 2026-03-15:
- Part with `offsetMinutes: -4320` (−3 days) → due 2026-03-12
- Part with `offsetMinutes: 0` (anchor) → due 2026-03-15
- Part with `offsetMinutes: 1440` (+1 day) → due 2026-03-16

## Data Model

Expand All @@ -72,7 +136,6 @@ Open [http://localhost:3000](http://localhost:3000).
userId: string // Google email
name: string
color: string // hex
defaultCalendarId?: string
parts: [
{
id: string
Expand All @@ -96,8 +159,8 @@ Open [http://localhost:3000](http://localhost:3000).
templateId: string
templateName: string
anchorDate: string // ISO 8601
calendarId: string
eventIds: string[] // Google Calendar event IDs
calendarId: string // Google Tasks task list ID
eventIds: string[] // Google Tasks task IDs
createdAt: string
}
```
Expand All @@ -111,6 +174,6 @@ Open [http://localhost:3000](http://localhost:3000).
| GET | `/api/templates/:id` | Get template |
| PUT | `/api/templates/:id` | Update template |
| DELETE | `/api/templates/:id` | Delete template |
| GET | `/api/event-groups` | List event groups |
| POST | `/api/event-groups` | Instantiate template → create Calendar events |
| GET | `/api/calendars` | List user's Google Calendars |
| GET | `/api/event-groups` | List task groups |
| POST | `/api/event-groups` | Instantiate template → create Tasks |
| GET | `/api/calendars` | List user's Google Task lists |
5 changes: 1 addition & 4 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,20 @@ export const authOptions: NextAuthOptions = {
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
// Request offline access so we get a refresh token
access_type: 'offline',
prompt: 'consent',
scope: [
'openid',
'email',
'profile',
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/tasks',
].join(' '),
},
},
}),
],
callbacks: {
async jwt({ token, account }) {
// Persist the OAuth access_token and refresh_token on first sign-in
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
Expand All @@ -32,7 +30,6 @@ export const authOptions: NextAuthOptions = {
return token
},
async session({ session, token }) {
// Expose tokens to the client session
session.accessToken = token.accessToken as string
return session
},
Expand Down
60 changes: 60 additions & 0 deletions src/lib/googleTasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { google } from 'googleapis'
import dayjs from 'dayjs'

/**
* Returns an authenticated Google Tasks client using the user's access token.
*/
export function getTasksClient(accessToken: string) {
const auth = new google.auth.OAuth2()
auth.setCredentials({ access_token: accessToken })
return google.tasks({ version: 'v1', auth })
}

/**
* Fetch all task lists for the authenticated user.
*/
export async function listTaskLists(accessToken: string) {
const tasks = getTasksClient(accessToken)
const res = await tasks.tasklists.list({ maxResults: 100 })
return res.data.items ?? []
}

export interface TaskPart {
title: string
/** Offset in minutes relative to the anchor date (negative = before, positive = after) */
offsetMinutes: number
description?: string
}

/**
* Creates multiple tasks from a list of TaskParts and an anchor date.
* Returns the created task IDs.
*/
export async function createTasksFromParts(
accessToken: string,
taskListId: string,
anchorDate: string, // ISO 8601
parts: TaskPart[]
): Promise<string[]> {
const tasks = getTasksClient(accessToken)
const anchor = dayjs(anchorDate)
const createdIds: string[] = []

for (const part of parts) {
const due = anchor.add(part.offsetMinutes, 'minute')

const res = await tasks.tasks.insert({
tasklist: taskListId,
requestBody: {
title: part.title,
notes: part.description,
// Google Tasks due date must be in RFC 3339 format with time set to 00:00:00Z
due: due.toDate().toISOString().replace(/T.*/, 'T00:00:00.000Z'),
},
})

if (res.data.id) createdIds.push(res.data.id)
}

return createdIds
}
6 changes: 3 additions & 3 deletions src/pages/api/calendars.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { listCalendars } from '@/lib/googleCalendar'
import { listTaskLists } from '@/lib/googleTasks'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions)
if (!session?.user?.email || !session.accessToken)
return res.status(401).json({ error: 'Unauthorized' })

const calendars = await listCalendars(session.accessToken as string)
return res.status(200).json(calendars)
const taskLists = await listTaskLists(session.accessToken as string)
return res.status(200).json(taskLists)
}
13 changes: 6 additions & 7 deletions src/pages/api/event-groups/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { db } from '@/lib/firestore'
import { createEventsFromParts } from '@/lib/googleCalendar'
import { createTasksFromParts } from '@/lib/googleTasks'
import { Template, EventGroup } from '@/types'
import { v4 as uuidv4 } from 'uuid'

Expand All @@ -24,7 +24,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}

if (req.method === 'POST') {
const { templateId, anchorDate, calendarId } = req.body as {
const { templateId, anchorDate, calendarId: taskListId } = req.body as {
templateId: string
anchorDate: string
calendarId: string
Expand All @@ -35,15 +35,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!tDoc.exists) return res.status(404).json({ error: 'Template not found' })
const template = tDoc.data() as Template

// Create all events in Google Calendar
const eventIds = await createEventsFromParts(
// Create all tasks in Google Tasks
const eventIds = await createTasksFromParts(
accessToken,
calendarId,
taskListId,
anchorDate,
template.parts.map(p => ({
title: p.title,
offsetMinutes: p.offsetMinutes,
durationMinutes: p.durationMinutes,
description: p.description,
}))
)
Expand All @@ -54,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
templateId,
templateName: template.name,
anchorDate,
calendarId,
calendarId: taskListId,
eventIds,
createdAt: new Date().toISOString(),
}
Expand Down
Loading