4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

インスタでよく見る営業カレンダーを3分で作れるアプリを、怒り駆動開発で作った話

4
Posted at

はじめに

飲食店や美容室では、Instagramに画像としてカレンダーを投稿して営業日や予約状況を周知する文化があります。
ほか、手書きやExcelで作っている年配のオーナーさんも多いのではないでしょうか。

このシンプルなニーズに対して、海外製の有料サービスが検索上位を占める中、必ずしも効率的とは言えない情報が広まっている現状があります。

QRコードも、検索上位のサービスの多くが海外サイトであり、URL短縮サービスを介しているため、意図しないリダイレクトが発生する可能性があります。
知らずに不正確なQRコードを配布してしまうと、お店でスキャンしたユーザーが広告サイトに飛ばされてても、オーナーは気づけません。

「もっとシンプルで、無料で、3分で作れるサービスがあれば、みんなハッピーになれるっピね!」

3 min. Calendar(スリーミン)は、3分間でおしゃれな営業カレンダーを作れるWebアプリです。

中華とかインド料理屋とかよく行くので、日本で頑張る外国人オーナー(11言語)に対応させました。

作ったもの

主な機能

  • 📅 月間カレンダーの作成と画像エクスポート
  • 🌍 11言語対応(日本語、英語、中国語、韓国語、ネパール語、タイ語、ベトナム語、フィリピン語、スペイン語、ポルトガル語、フランス語)
  • 🎨 8種類のカラーテーマ(ライト/ダーク)
  • 📱 完全オフライン動作可能なPWA
  • 🔒 データは全てブラウザのローカルストレージに保存(サーバー送信なし)
  • 🎯 QRコード生成機能

技術スタック

  • React 18
  • TypeScript
  • Vite
  • Tailwind CSS
  • Zustand(状態管理)
  • i18next(多言語対応)
  • Konva(Canvas描画)
  • Workbox(PWA)

技術的な工夫

1. 完全クライアントサイドで動作

バックエンドサーバーなし、全てブラウザ内で完結する設計にしました。

なぜサーバーレスにしたのか

  • プライバシー重視: 営業情報をサーバーに送信したくないニーズ
  • 運用コスト削減: サーバー費用ゼロで永続的に運営可能
  • オフライン動作: インターネット接続なしでも使える

実装のポイント

// Zustand + localStorage で状態永続化
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useCalendarStore = create(
  persist(
    (set, get) => ({
      entries: [],
      settings: defaultSettings,
      // ... state and actions
    }),
    {
      name: '3min-calendar-storage',
      version: 1,
    }
  )
)

2. 11言語対応の実装

言語選定の背景

作者が金沢で見かけた店舗オーナーさんの母国語から選びました:

  • インド・ネパール料理店 → ネパール語
  • 中華料理店 → 中国語
  • ベトナム料理店 → ベトナム語
  • タイ料理店 → タイ語
  • フィリピン系 → タガログ語
  • 南米料理店 → スペイン語、ポルトガル語
  • フレンチ → フランス語

i18next の設定

import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'

// ブラウザの言語を自動検出
const detectLanguage = (): SupportedLanguage => {
  const browserLang = navigator.language.split('-')[0]
  if (SUPPORTED_LANGUAGES.includes(browserLang as SupportedLanguage)) {
    return browserLang as SupportedLanguage
  }
  return 'en'
}

i18n.use(initReactI18next).init({
  resources: {
    ja: { translation: ja },
    en: { translation: en },
    zh: { translation: zh },
    // ... 他の言語
  },
  lng: detectLanguage(),
  fallbackLng: 'en',
  interpolation: {
    escapeValue: false,
  },
})

言語別フォントの最適化

言語によってフォントを切り替える仕組みを実装しました。

export function useLanguageFont() {
  const { i18n } = useTranslation()
  
  const fontFamily = useMemo(() => {
    const lang = i18n.language
    if (lang === 'zh') return '"Noto Sans SC", sans-serif'
    if (lang === 'ja') return '"Zen Kaku Gothic Antique", sans-serif'
    if (lang === 'ko') return '"Noto Sans KR", sans-serif'
    if (lang === 'th') return '"Noto Sans Thai", sans-serif'
    // ...
    return 'Inter, system-ui, sans-serif'
  }, [i18n.language])
  
  return fontFamily
}

言語による年、月の表示順の違いとか、それに対する自動的な位置調整も実現済みです。

3. Canvas でカレンダー描画

なぜ Canvas を選んだのか

  • DOM操作よりも高速にレンダリング
  • デザインの細かい制御が可能
  • ブラウザでの見た目とLINEに貼る画像が完全一致する(← 重要!)

そもそもCanvaの使いにくさに衝撃を受けて、「うそっ、こんな非効率なことを全国のオーナーは毎月やってるの……!?」
に対するアンサーとして作ったアプリなのに、Canvasという似た名前の枯れた技術を使ってる。

Canvasがベストだと気づくまでに、html2canvasで試行錯誤した10時間があったんじゃよ……

Konva (react-konva) の活用

export const CalendarGridCanvas = forwardRef<CalendarGridCanvasHandle>(
  function CalendarGridCanvas({ comment }, ref) {
    const stageRef = useRef<Konva.Stage>(null)
    
    // エクスポート用メソッド
    useImperativeHandle(ref, () => ({
      toDataURL: (pixelRatio = 2) => {
        if (!stageRef.current) return ''
        return stageRef.current.toDataURL({ pixelRatio })
      },
    }))
    
    return (
      <Stage ref={stageRef} width={size} height={size}>
        <Layer>
          {/* 背景 */}
          <Rect fill={theme.surface} />
          
          {/* 背景画像(アスペクト比維持) */}
          {backgroundImage && (
            <Group clipFunc={(ctx) => ctx.rect(0, 0, BASE_SIZE, BASE_SIZE)}>
              <KonvaImage
                image={backgroundImage}
                x={offsetX}
                y={offsetY}
                width={drawWidth}
                height={drawHeight}
                opacity={settings.backgroundOpacity}
              />
            </Group>
          )}
          
          {/* カレンダーグリッド */}
          {days.map((day, index) => (
            <Group key={day.dateString}>
              {/* セル描画 */}
            </Group>
          ))}
        </Layer>
      </Stage>
    )
  }
)

背景画像のアスペクト比維持

最初は画像を正方形に変形していましたが、ユーザーからのフィードバックで改善:

// アスペクト比を維持して、短い方の辺に合わせてクロップ
const imgAspect = imgWidth / imgHeight
const canvasAspect = BASE_SIZE / BASE_SIZE

let drawWidth: number
let drawHeight: number
let offsetX = 0
let offsetY = 0

if (imgAspect > canvasAspect) {
  // 画像が横長 → 高さに合わせて、左右をクロップ
  drawHeight = BASE_SIZE
  drawWidth = drawHeight * imgAspect
  offsetX = -(drawWidth - BASE_SIZE) / 2
} else {
  // 画像が縦長 → 幅に合わせて、上下をクロップ
  drawWidth = BASE_SIZE
  drawHeight = drawWidth / imgAspect
  offsetY = -(drawHeight - BASE_SIZE) / 2
}

4. PWA対応

vite-plugin-pwa の設定

import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
      manifest: {
        name: '3 min.',
        short_name: '3 min.',
        description: '3分でできる、店舗用の営業カレンダー',
        theme_color: '#ffffff',
        background_color: '#ffffff',
        display: 'standalone',
        icons: [
          {
            src: 'pwa-192x192.png',
            sizes: '192x192',
            type: 'image/png',
          },
          {
            src: 'pwa-512x512.png',
            sizes: '512x512',
            type: 'image/png',
          },
        ],
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
            handler: 'CacheFirst',
            options: {
              cacheName: 'google-fonts-cache',
              expiration: {
                maxEntries: 10,
                maxAgeSeconds: 60 * 60 * 24 * 365, // 1年
              },
            },
          },
        ],
      },
    }),
  ],
})

5. QRコード生成機能

店舗のURLやSNSリンクをQRコードにできる機能を追加しました。

import { QRCode } from 'react-qrcode-logo'

<QRCode
  value={url}
  size={size}
  ecLevel="H"
  bgColor={isTransparent ? 'transparent' : bgColor}
  fgColor={fgColor}
  qrStyle={qrStyle}
  eyeRadius={getEyeRadius(size, eyeStyle)}
  logoImage={logoImage || undefined}
  logoWidth={logoSize.width}
  logoHeight={logoSize.height}
  removeQrCodeBehindLogo
  quietZone={0}
  enableCORS
/>

6. 月ごとのカレンダーテーマ

グローバル設定とは別に、月ごとに個別のテーマを設定できる機能を実装しました。例えば、12月だけクリスマスカラーにする、といった使い方ができます。

// 月ごとのカレンダーテーマ
const monthKey = `${view.year}-${String(view.month + 1).padStart(2, '0')}`
const calendarThemeId = calendarThemes[monthKey] ?? settings.calendarTheme
const theme = THEMES[calendarThemeId]

この実装により、同じアプリ設定のまま、月によって雰囲気を変えられるようになりました。

7. 選択枠のスムーズなアニメーション

日付をクリックしたときに選択枠がヌルっと移動するアニメーションを実装しました。requestAnimationFrame とイージング関数を使った滑らかな動きにこだわりました。

// easeOutCubic で滑らかに移動
const animate = (currentTime: number) => {
  const elapsed = currentTime - startTime
  const progress = Math.min(elapsed / duration, 1)
  // easeOutCubic: 減速しながら到達
  const eased = 1 - Math.pow(1 - progress, 3)

  setAnimatedPos({
    x: startPos.x + (targetPos.x - startPos.x) * eased,
    y: startPos.y + (targetPos.y - startPos.y) * eased,
  })

  if (progress < 1) {
    animationRef.current = requestAnimationFrame(animate)
  }
}

細かい部分ですが、こういったマイクロインタラクションがアプリの質を高めると考えています。

……というか、スクロールバーをいちいち操作してたら、3分の壁は切れないことが判明したからだよ!

8. 背景記号のブレンドモード切り替え


カレンダーに表示する背景記号(◯△✕)を、テーマに応じてブレンドモードを変えることで、ダークテーマでもライトテーマでも見やすくしました。

// ダークテーマ: screen合成で明るく、ライトテーマ: 通常合成で暗く
const isDarkTheme = calendarThemeId.startsWith('dark')
const blendMode = isDarkTheme ? 'screen' : 'source-over'
const symbolOpacity = isDarkTheme ? 0.5 : 0.3

<Group globalCompositeOperation={blendMode}>
  {symbolStyle.key === 'available' && (
    <Circle
      x={cx}
      y={cy}
      radius={Math.min(cellWidth, cellHeight) * 0.35}
      stroke={symbolStyle.bgColor}
      strokeWidth={4}
      opacity={symbolOpacity}
    />
  )}
</Group>

Canvas の globalCompositeOperation を活用することで、同じ色でもテーマによって適切に表示されます。

9. 日本の祝日・六曜対応

date-holidays で祝日取得

import Holidays from 'date-holidays'

export function isHoliday(date: Date): boolean {
  const country = settings.holidayCountry
  if (!country) return false
  
  const hd = new Holidays(country)
  const holidays = hd.isHoliday(date)
  
  return holidays ? holidays.length > 0 : false
}

六曜の計算

旧暦ベースの六曜(大安、仏滅など)を計算する実装:

import { CalendarChinese } from 'date-chinese'

export function getRokuyo(date: Date): number {
  const cal = new CalendarChinese()
  const result = cal.fromGregorian(date)
  const month = result.month
  const day = result.day
  
  // 六曜の計算式
  return (month + day) % 6
}

和暦、月曜始まり表示も、同様に計算で対応しています。

ラマダンにも対応しようと思ったけど、ルールが複雑だったのでやめました。

10. 型安全な状態管理

Zustand + TypeScript で型安全な状態管理を実現:

interface CalendarEntry {
  date: string
  symbol?: SymbolKey
  stamp?: StampKey
  timeFrom?: string
  timeTo?: string
  text?: string
}

interface CalendarState {
  entries: CalendarEntry[]
  settings: Settings
  view: { year: number; month: number }
  selectedDate: string | null
  
  // Actions
  addOrUpdateEntry: (entry: CalendarEntry) => void
  deleteEntry: (date: string) => void
  updateSettings: (settings: Partial<Settings>) => void
  // ...
}

苦労した点

1. 多言語フォントの読み込み

11言語分のフォントを全て読み込むとファイルサイズが大きくなるため、必要な文字セットのみを Google Fonts から読み込むように最適化しました。

/* index.css */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Thai:wght@400;500;700&display=swap');
/* ... */

言語を切り替えた瞬間に、追加フォントをダウンロードする実装になってるので、大半の日本人のギガは減りません。

2. 月によって異なる行数への対応

カレンダーの行数は月によって変動します。通常は5行か6行ですが、2026年2月は日曜始まりで28日間なので、なんと4行で収まります。これは結構レアなケースです!

このような変動に対応するため、動的にセルの高さを計算する仕組みを実装しました。

さらに、カジュアルなカードスタイルフォーマルな罫線スタイルの2種類に対応しています。病院や官公庁など、フォーマルな場面では罫線スタイルが好まれることが多いためです。

const rowCount = Math.ceil(days.length / 7)
const cellHeight = isLinedStyle
  ? gridHeight / rowCount  // 罫線スタイル:境界線で区切る
  : (gridHeight - GAP * (rowCount - 1)) / rowCount  // カードスタイル:余白で区切る

セルの描画も、スタイルに応じて呼び分けています:

<Rect
  width={cellWidth}
  height={cellHeight}
  fill={cellBgColor}
  cornerRadius={isLinedStyle ? 0 : CELL_RADIUS}  // 罫線は角丸なし
  stroke={isLinedStyle ? lineColor : undefined}  // 罫線スタイルのみ枠線
  strokeWidth={isLinedStyle ? 0.5 : 0}
/>

3. Canvas の高解像度対応

Retina ディスプレイでも綺麗に表示されるよう、pixelRatio を考慮:

toDataURL: (pixelRatio = 2) => {
  if (!stageRef.current) return ''
  return stageRef.current.toDataURL({ pixelRatio })
}

ユーザーからのフィードバック

実際に使ってもらって改善した点:

  1. 背景画像の変形問題 → アスペクト比維持に修正
  2. QRコードの背景色が反映されないquietZone={0}enableCORS を追加
  3. 言語設定がわかりにくい → README に各言語の日本語名を追加
  4. テーマの切り替えが分かりづらい → 月ごとにテーマを設定できる機能を追加

もし店舗を経営されている方、または店舗オーナーのお知り合いがいらっしゃれば、ぜひ使ってみてください!


最後まで読んでいただきありがとうございました!

この開発は、親戚が奥能登で復興の協力店を作ろうとして、カレンダーやQRコードの罠を踏みそうになってるのを見て、代理で調べたことがキッカケでした。

ITリテラシーの低い個人経営者を狙った業者界隈を調べるほど、悲しく……
吉良吉影にキレる康一くんみたいに「違うんじゃあないか……ッ!?」な怒りが湧いてきて、翌日には基本部分が動作していました。
つまり、怒り駆動開発だったのです。

超喜んでもらえたので、キレイにしておすそ分けできる成果物にしたのが、このアプリです。

質問や感想があれば、コメント欄でお待ちしています 🙌

リポジトリ:

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?